11. Étude de cas : l'application RdvMedecins
11.1. Le problème
L'application RdvMedecins gère les rendez-vous d'un cabinet de médecins. Chaque médecin a des créneaux horaires (de 8 h 00 à 12 h 00 par tranches de 20 minutes, par exemple) ; un rendez-vous associe un jour, un créneau et un client (un patient). C'est la transposition exacte, en [Spring MVC], de l'application [ASP.NET Core MVC] du cours précédent : même base de données, mêmes règles de gestion, mêmes pages, mêmes adresses, mêmes dictionnaires de traduction. Les pages HTML sont fabriquées par le serveur, avec des vues [Thymeleaf] ; l'accès à la base passe par [Spring Data JPA] et [Hibernate].
Trois rôles d'utilisateurs se partagent l'application :
Rôle | Qui | Ce qu'il peut faire |
ADMIN | l'administrateur ([admin]) | consulter les agendas, réserver et annuler pour n'importe quel patient ; gérer (lister, ajouter, modifier, supprimer) les médecins et les clients |
DOCTOR | les médecins | consulter les agendas, réserver et annuler pour n'importe quel patient |
USER | les clients (patients) | consulter les agendas sans voir le nom des autres patients ; réserver pour lui-même ; annuler son rendez-vous ; gérer son compte ; créer son compte |
Les règles de gestion principales :
- pas de rendez-vous dans le passé ;
- un créneau n'est réservé qu'une fois par jour ;
- un patient n'a qu'un rendez-vous à venir à la fois ;
- deux médecins (ou deux clients) ne portent pas les mêmes prénom et nom, et un login n'appartient qu'à un compte ;
- un médecin qui a des rendez-vous ne peut pas être supprimé ; un client est supprimé avec ses rendez-vous ;
- la page de connexion est protégée des robots (captcha, limitation des tentatives), et la création d'un compte client exige d'avoir d'abord résolu le captcha de cette page.
11.2. Utilisation de l'application
11.2.1. La page de connexion

La page propose deux boutons : Se connecter, et Créer un compte client. Tous deux exigent d'avoir recopié les caractères de l'image. Les boutons FR / EN changent la langue :

Une erreur (ici un captcha mal recopié) est signalée en bas de la page, sur un fond rose léger :

11.2.2. L'agenda, vu par l'administrateur et les médecins
Une fois connecté, l'administrateur arrive sur la page de l'agenda. Il choisit un médecin et un jour :


Chaque créneau est libre (bouton Réserver) ou occupé (nom du patient, bouton Annuler). Un clic sur Réserver ouvre une fenêtre qui demande le patient. Cette fenêtre est fabriquée par le serveur, comme le reste de la page : l'adresse contient désormais le paramètre [reserver=4], le numéro du créneau choisi :

Un clic sur Annuler demande une confirmation (ici par le docteur Pélissier, dont la barre de navigation n'a pas les menus d'administration) :

11.2.3. L'espace du patient
Un patient (rôle USER) arrive sur Mon espace : son rendez-vous à venir, qu'il peut annuler, et des liens vers la gestion de son compte et vers l'agenda :

Dans l'agenda, il voit son propre rendez-vous (en bleu), mais pas le nom des autres patients, remplacé par « Réservé » : ce nom ne figure même pas dans le code source de la page. Ayant déjà un rendez-vous à venir, il ne peut pas en prendre un autre : aucun bouton Réserver n'est affiché (sur l'agenda d'un autre jour ou d'un autre médecin, où son rendez-vous n'apparaît pas, un message lui explique pourquoi) :

Un patient sans rendez-vous peut réserver un créneau libre, pour lui-même : la fenêtre de réservation ne propose pas de choisir le patient.

La gestion de son compte : modifier ses informations, son mot de passe, ou supprimer son compte (et ses rendez-vous). Le mot de passe actuel est exigé pour toute modification :

Un patient qui tente d'ouvrir une page réservée à l'administrateur (ici [/medecins], tapée à la main) reçoit une page d'erreur :

11.2.4. La gestion des médecins et des clients
L'administrateur a deux menus supplémentaires, Médecins et Clients :


Le formulaire d'ajout d'un médecin, envoyé avec des erreurs :

Un homonyme d'un médecin existant est refusé, même écrit en minuscules (nous verrons que c'est la base de données elle-même qui compare les noms sans tenir compte des majuscules ni des accents) :

La suppression d'un client demande une confirmation :

11.2.5. La création d'un compte client
Depuis la page de connexion, le bouton Créer un compte client n'ouvre le formulaire que si le captcha a été correctement recopié :


11.3. Installation et exécution
L'application se trouve dans le dossier [rdvmedecins2-spring-mvc], à côté du dossier [exemples]. C'est un projet [Maven] autonome : il ne dépend pas du projet parent des exemples, et possède son propre Maven Wrapper (cf. plus bas). Le fichier [README.md] du dossier reprend tout ce qui suit.
11.3.1. Les logiciels nécessaires
Deux logiciels suffisent :
- un JDK 21 ([Java Development Kit]), qui contient le compilateur [javac] et la machine virtuelle [java]. [Java] 21 est une version LTS (support à long terme). Une distribution gratuite convient, par exemple [Eclipse Temurin] (https://adoptium.net) ; sous [Linux], le paquet [openjdk-21-jdk]. On vérifie l'installation dans un terminal :
| openjdk version "21.0.10" 2026-01-20
OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04)
OpenJDK 64-Bit Server VM (build 21.0.10+7-Ubuntu-124.04, mixed mode, sharing)
|
- un serveur [MySQL] 8, par exemple celui de [Laragon] sous [Windows] (cf. exemple 28). [MariaDB] convient aussi : l'application a été testée avec lui.
[Maven] n'a pas besoin d'être installé : le script [mvnw] (Maven Wrapper) le télécharge lui-même au premier lancement, puis [Maven] télécharge les bibliothèques du projet depuis le dépôt [Maven Central]. Il faut donc être connecté à Internet lors de la première compilation. L'éditeur conseillé est [Visual Studio Code], avec l'« Extension Pack for Java » et le « Spring Boot Extension Pack » ; mais tout peut se faire en ligne de commande.
11.3.2. Créer la base de données
La base [dbrdvmedecins2] est créée par le script [database/dbrdvmedecins2.sql]. Avec [Laragon], on ouvre l'outil [HeidiSQL] (bouton Base de données), puis on exécute le script par le menu Fichier / Exécuter un fichier SQL. En ligne de commande, dans le dossier de l'application :
| mysql -u root -p < database/dbrdvmedecins2.sql
|
L'option [-p] fait demander le mot de passe de [root] ; on l'omet si le compte n'en a pas (c'est le cas avec [Laragon]). C'est la même base que celle de la version [ASP.NET Core] : si elle existe déjà, cette étape est facultative. Le script peut être exécuté à tout moment pour revenir aux données de départ.
Toute la configuration est dans le fichier [src/main/resources/application.properties] (commenté plus loin). Par défaut, l'application se connecte à [MySQL] sur [localhost], port 3306, avec l'utilisateur [root] sans mot de passe : les réglages par défaut de [Laragon]. Si votre compte [root] a un mot de passe, le plus simple est de l'écrire dans ce fichier, à la clé [spring.datasource.password]. Pour ne pas écrire de mot de passe dans un fichier du projet, on peut aussi le donner par une variable d'environnement, qui l'emporte sur le fichier. Son nom se déduit de celui de la clé : majuscules, [.] et [-] remplacés par [_] :
| export SPRING_DATASOURCE_PASSWORD=monMotDePasse # Linux, macOS
set SPRING_DATASOURCE_PASSWORD=monMotDePasse # Windows (cmd)
$env:SPRING_DATASOURCE_PASSWORD="monMotDePasse" # Windows (PowerShell)
|
La variable ne vaut que pour le terminal où elle a été définie : on y lance ensuite l'application.
11.3.4. Lancer l'application
On ouvre le dossier [rdvmedecins2-spring-mvc] dans [Visual Studio Code] (menu Fichier / Ouvrir le dossier), puis un terminal (menu Terminal / Nouveau terminal), et on tape :
Sous [Windows], on tape [mvnw spring-boot:run] dans l'invite de commandes, ou [.\mvnw spring-boot:run] dans [PowerShell]. À la première exécution, [mvnw] télécharge [Maven], qui télécharge à son tour les bibliothèques, compile l'application, puis la démarre. Après les messages de [Maven], la console affiche ceux de l'application :
| . ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-09-25T18:46:42.404+02:00 INFO 19025 --- [ restartedMain] rdvmedecins.RdvMedecinsApplication : Starting RdvMedecinsApplication using Java 21.0.10 with PID 19025 (/home/serge/cours/rdvmedecins2-spring-mvc/target/classes started by serge in /home/serge/cours/rdvmedecins2-spring-mvc)
2026-09-25T18:46:42.422+02:00 INFO 19025 --- [ restartedMain] rdvmedecins.RdvMedecinsApplication : The following 1 profile is active: "dev"
2026-09-25T18:46:42.598+02:00 INFO 19025 --- [ restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2026-09-25T18:46:42.598+02:00 INFO 19025 --- [ restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2026-09-25T18:46:43.729+02:00 INFO 19025 --- [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-09-25T18:46:43.795+02:00 INFO 19025 --- [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 55 ms. Found 5 JPA repository interfaces.
2026-09-25T18:46:44.511+02:00 INFO 19025 --- [ restartedMain] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8081 (http)
2026-09-25T18:46:44.524+02:00 INFO 19025 --- [ restartedMain] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-09-25T18:46:44.525+02:00 INFO 19025 --- [ restartedMain] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-09-25T18:46:44.552+02:00 INFO 19025 --- [ restartedMain] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1949 ms
2026-09-25T18:46:44.747+02:00 INFO 19025 --- [ restartedMain] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-09-25T18:46:44.783+02:00 INFO 19025 --- [ restartedMain] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-09-25T18:46:45.287+02:00 INFO 19025 --- [ restartedMain] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-09-25T18:46:45.317+02:00 INFO 19025 --- [ restartedMain] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-09-25T18:46:45.487+02:00 INFO 19025 --- [ restartedMain] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@4a05c6b0
2026-09-25T18:46:45.488+02:00 INFO 19025 --- [ restartedMain] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-09-25T18:46:45.537+02:00 WARN 19025 --- [ restartedMain] org.hibernate.orm.core : HHH000511: The 5.5.5 version for [org.hibernate.dialect.MySQLDialect] is no longer supported, hence certain features may not work properly.The minimum supported version is 8.0.0. Check the community dialects project for available legacy versions.
2026-09-25T18:46:45.554+02:00 INFO 19025 --- [ restartedMain] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:mysql://localhost:3306/dbrdvmedecins2]
Database driver: MySQL Connector/J
Database dialect: MySQLDialect
Database version: 5.5.5
Default catalog/schema: dbrdvmedecins2/undefined
Autocommit mode: undefined/unknown
Isolation level: REPEATABLE_READ [default REPEATABLE_READ]
JDBC fetch size: none
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-09-25T18:46:47.573+02:00 INFO 19025 --- [ restartedMain] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-09-25T18:46:47.597+02:00 INFO 19025 --- [ restartedMain] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-25T18:46:47.688+02:00 INFO 19025 --- [ restartedMain] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
Hibernate: select m1_0.id,m1_0.`user`,m1_0.nom,m1_0.prenom,m1_0.titre,u1_0.id,u1_0.login,u1_0.nom,u1_0.password,u1_0.role,u1_0.version,m1_0.version from medecins m1_0 left join users u1_0 on u1_0.id=m1_0.`user` order by m1_0.nom,m1_0.prenom
Hibernate: select c1_0.id,c1_0.`user`,c1_0.nom,c1_0.prenom,c1_0.titre,u1_0.id,u1_0.login,u1_0.nom,u1_0.password,u1_0.role,u1_0.version,c1_0.version from clients c1_0 left join users u1_0 on u1_0.id=c1_0.`user` order by c1_0.nom,c1_0.prenom
2026-09-25T18:46:52.549+02:00 INFO 19025 --- [ restartedMain] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8081 (http) with context path '/'
2026-09-25T18:46:52.563+02:00 INFO 19025 --- [ restartedMain] rdvmedecins.RdvMedecinsApplication : Started RdvMedecinsApplication in 10.82 seconds (process running for 11.321)
2026-09-25T18:46:52.928+02:00 INFO 19025 --- [omcat-handler-0] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-09-25T18:46:52.929+02:00 INFO 19025 --- [omcat-handler-0] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-09-25T18:46:52.930+02:00 INFO 19025 --- [omcat-handler-0] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
|
Nous reviendrons sur cette console dans la section « Le démarrage de l'application ». Retenons pour l'instant la ligne [Tomcat started on port 8081] : l'application est prête, à l'adresse [http://localhost:8081]. Le port 8081 permet de faire tourner en même temps un exemple du cours (port 5000). La version [ASP.NET Core] de l'application écoute elle aussi sur le port 8081 : on ne lance pas les deux à la fois.
On arrête l'application par [Ctrl-C] dans le terminal.
11.3.5. Les comptes de démonstration
Le mot de passe de chaque compte est son login ; il faut aussi recopier les caractères de l'image, en majuscules ou en minuscules :
Login | Rôle |
[admin] | ADMIN (l'administrateur) |
[pelissier], [tatou], [dupont] | DOCTOR (les médecins) |
[martin], [durand], [bistrou], [german] | USER (les patients) |
Un parcours pour découvrir l'application :
- [admin] : Agenda, Mme Marie PELISSIER, aujourd'hui : un créneau est déjà pris par Melle Brigitte BISTROU. Réserver un créneau pour Mr Jean MARTIN, puis tenter un second rendez-vous pour lui (refusé). Menus Médecins et Clients : ajouter, modifier, supprimer (le docteur PELISSIER, qui a des rendez-vous, ne peut pas être supprimé) ;
- [pelissier] : son agenda du jour s'affiche directement ; l'adresse [/medecins], tapée à la main, donne une page 403 ;
- [martin] : Mon espace, son rendez-vous, l'agenda où les autres patients apparaissent comme « Réservé » ; Gérer mon compte ;
- page de connexion : Créer un compte client, puis se connecter avec le nouveau compte ;
- boutons FR / EN : toute l'interface change de langue, dates comprises.
11.3.6. Le mode développement
La commande [./mvnw spring-boot:run] lance l'application avec le profil [dev] (cf. [pom.xml], plus loin) : la console affiche alors chaque ordre SQL envoyé à la base par [Hibernate] (les deux lignes [Hibernate: select ...] de la console ci-dessus). Deux autres outils facilitent le développement :
- la bibliothèque [Spring Boot DevTools] (cf. [pom.xml]) redémarre l'application toute seule dès qu'une classe est recompilée dans le dossier [target/classes]. [Visual Studio Code] recompile un fichier [.java] à chaque enregistrement ; sans lui, on tape [./mvnw compile] dans un second terminal. Le redémarrage ne prend que quelques secondes : seules les classes de l'application sont rechargées, pas les bibliothèques. Une vue [templates/*.html] modifiée est, elle, relue au prochain affichage de la page, une fois recopiée dans [target/classes] de la même façon ;
- le débogueur : avec l'« Extension Pack for Java », les liens Run et Debug s'affichent au-dessus de la méthode [main] de [RdvMedecinsApplication.java] ; Debug démarre l'application sous le contrôle du débogueur. On pose un point d'arrêt d'un clic dans la marge d'une ligne de code. La vue Spring Boot Dashboard du « Spring Boot Extension Pack » offre les mêmes commandes.
11.3.7. Une archive exécutable
Pour installer l'application sur un serveur, on la réunit en une seule archive :
| ./mvnw package
java -jar target/rdvmedecins-1.0.jar
|
La première commande compile l'application et fabrique l'archive [target/rdvmedecins-1.0.jar] (environ 60 Mo) : elle contient l'application, toutes ses bibliothèques, et le serveur [Tomcat] lui-même. La seconde la lance : seul un JDK (ou un JRE) 21 est nécessaire sur la machine. Lancée ainsi, l'application n'a pas le profil [dev] (la console n'affiche pas les ordres SQL), et [Spring Boot DevTools] n'est pas dans l'archive. Chaque clé de [application.properties] peut être remplacée sur la ligne de commande, par exemple [java -jar target/rdvmedecins-1.0.jar --server.port=9000].
11.3.8. Les problèmes fréquents
Au démarrage, [Hibernate] interroge la base pour connaître sa version, puis l'application y lit les listes des médecins et des clients : si la base est inaccessible, l'application s'arrête aussitôt, avec l'erreur dans la console. Cette erreur est souvent noyée dans une longue pile d'appels, et la dernière ligne [Caused by: ...] n'est pas la plus parlante ([Unable to determine Dialect without JDBC metadata] : [Hibernate] n'a pas pu interroger la base). On cherche plutôt le message de la base elle-même, qu'[Hibernate] écrit aussi dans un avertissement, au début de l'erreur. Par exemple, avec une base qui n'existe pas :
| ... WARN ... org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 1049, SQLState: 42000
... WARN ... org.hibernate.orm.jdbc.error : Unknown database 'dbinconnue'
...
Caused by: java.sql.SQLSyntaxErrorException: Unknown database 'dbinconnue'
...
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
Les messages les plus fréquents :
Message | Cause probable |
[Communications link failure] ou [Connection refused] | [MySQL] n'est pas démarré, ou n'écoute pas sur [localhost:3306] |
[Access denied for user 'root'@'localhost'] | le mot de passe de [MySQL] est à indiquer (cf. « Configurer l'accès à la base ») |
[Unknown database 'dbrdvmedecins2'] | le script [database/dbrdvmedecins2.sql] n'a pas été exécuté |
[rdvmedecins.auth.jwt-secret doit comporter au moins 32 caractères] | la clé secrète des jetons est trop courte dans [application.properties] |
[Port 8081 was already in use] | le port 8081 est déjà pris (la version [ASP.NET Core] tourne-t-elle ?) : l'arrêter, ou changer [server.port] |
[JAVA_HOME is not set] ou [release version 21 not supported] | le JDK est absent, ou plus ancien que la version 21 |
[Could not transfer artifact] pendant [mvnw] | le dépôt [Maven Central] est inaccessible (pas d'Internet, proxy) |
Avec [MariaDB], [Hibernate] affiche en outre, au démarrage, un avertissement sans conséquence : [HHH000511: The 5.5.5 version for [org.hibernate.dialect.MySQLDialect] is no longer supported]. [MariaDB] se présente en effet au pilote [MySQL] avec un numéro de version « 5.5.5 », qu'[Hibernate] prend pour un très vieux [MySQL].
11.4. L'architecture de l'application

- le navigateur envoie ses requêtes au serveur [Tomcat], intégré à l'application. Chacune traverse d'abord la chaîne des filtres de [Spring Security] (configurée par [config/SecuriteConfig.java]), qui établit l'utilisateur à partir du jeton JWT de son cookie et refuse l'accès aux pages protégées ;
- [controllers] : les contrôleurs de la couche web. Ils passent tous par un bean unique, [ApplicationModel] (dossier [web]), qui garde en mémoire les listes de médecins et de clients et relaie les autres demandes à la couche métier ;
- [auth] : la connexion, les jetons JWT, le captcha, la limitation des tentatives, le laissez-passer de création de compte ;
- [web] : ce que la couche web a en commun : le cache et la façade [ApplicationModel], la traduction, les messages flash, la validation, les attributs communs à toutes les vues, le traitement des exceptions ; [models] : les modèles des formulaires et des vues ;
- [metier] : les règles de gestion et les transactions ;
- [repositories] : la couche DAO, une interface [Spring Data JPA] par table ;
- [entities] : les entités [JPA], que [Hibernate] lit et écrit dans la base ; [domain] : les objets de l'agenda, calculés par la couche métier ;
- [templates] : les vues [Thymeleaf], qui fabriquent les pages HTML ; [i18n] : les dictionnaires, utilisés par le traducteur ; [static] : la feuille de style, le script de la page et [Bootstrap] ;
- [config] : la configuration écrite en [Java] (sécurité, langue, messages flash, propriétés typées) ; [RdvMedecinsApplication.java] démarre le tout.
Le dossier de l'application :
| rdvmedecins2-spring-mvc/
├── .mvn/
│ └── wrapper/
│ └── maven-wrapper.properties
├── database/
│ ├── dbrdvmedecins2.sql
│ └── README.md
├── src/
│ └── main/
│ ├── java/
│ │ └── rdvmedecins/
│ │ ├── auth/
│ │ │ ├── AuthService.java
│ │ │ ├── Captcha.java
│ │ │ ├── CaptchaService.java
│ │ │ ├── DessinCaptcha.java
│ │ │ ├── LimiteurTentativesService.java
│ │ │ ├── PasseInscriptionService.java
│ │ │ ├── README.md
│ │ │ ├── SessionInvalideException.java
│ │ │ └── UtilisateurConnecte.java
│ │ ├── config/
│ │ │ ├── AuthProprietes.java
│ │ │ ├── ProtectionProprietes.java
│ │ │ ├── README.md
│ │ │ ├── SecuriteConfig.java
│ │ │ └── WebConfig.java
│ │ ├── controllers/
│ │ │ ├── AgendaController.java
│ │ │ ├── AuthController.java
│ │ │ ├── ClientsController.java
│ │ │ ├── ErreurController.java
│ │ │ ├── EspacePatientController.java
│ │ │ ├── InscriptionController.java
│ │ │ ├── LangueController.java
│ │ │ ├── MedecinsController.java
│ │ │ ├── PersonnesControllerBase.java
│ │ │ └── README.md
│ │ ├── domain/
│ │ │ ├── AgendaMedecinJour.java
│ │ │ ├── CreneauMedecinJour.java
│ │ │ └── README.md
│ │ ├── entities/
│ │ │ ├── AbstractEntity.java
│ │ │ ├── Client.java
│ │ │ ├── Creneau.java
│ │ │ ├── Medecin.java
│ │ │ ├── Personne.java
│ │ │ ├── README.md
│ │ │ ├── Role.java
│ │ │ ├── Rv.java
│ │ │ └── User.java
│ │ ├── metier/
│ │ │ ├── Dates.java
│ │ │ ├── DonneesPersonne.java
│ │ │ ├── Metier.java
│ │ │ ├── MetierException.java
│ │ │ ├── MetierService.java
│ │ │ └── README.md
│ │ ├── models/
│ │ │ ├── Formulaires.java
│ │ │ ├── ModelesVues.java
│ │ │ ├── README.md
│ │ │ └── ReglesSaisie.java
│ │ ├── repositories/
│ │ │ ├── ClientRepository.java
│ │ │ ├── CreneauRepository.java
│ │ │ ├── MedecinRepository.java
│ │ │ ├── PersonneRepository.java
│ │ │ ├── README.md
│ │ │ ├── RvRepository.java
│ │ │ └── UserRepository.java
│ │ ├── web/
│ │ │ ├── ApplicationModel.java
│ │ │ ├── CookieFlashMapManager.java
│ │ │ ├── Flash.java
│ │ │ ├── HtmlExceptionHandler.java
│ │ │ ├── MessageFlash.java
│ │ │ ├── ModeleCommun.java
│ │ │ ├── Outils.java
│ │ │ ├── README.md
│ │ │ ├── Traducteur.java
│ │ │ ├── TypeFlash.java
│ │ │ └── Validation.java
│ │ └── RdvMedecinsApplication.java
│ └── resources/
│ ├── i18n/
│ │ ├── en.json
│ │ └── fr.json
│ ├── static/
│ │ ├── css/
│ │ │ └── styles.css
│ │ ├── js/
│ │ │ └── app.js
│ │ └── lib/
│ │ └── bootstrap/
│ │ ├── css/
│ │ │ └── bootstrap.min.css
│ │ └── LICENSE
│ ├── templates/
│ │ ├── fragments/
│ │ │ ├── captcha.html
│ │ │ ├── champs-personne.html
│ │ │ ├── confirmation.html
│ │ │ ├── erreurs.html
│ │ │ ├── flash.html
│ │ │ ├── navbar.html
│ │ │ └── reservation.html
│ │ ├── personnes/
│ │ │ ├── formulaire.html
│ │ │ └── liste.html
│ │ ├── agenda.html
│ │ ├── compte.html
│ │ ├── erreur.html
│ │ ├── espace.html
│ │ ├── inscription.html
│ │ ├── layout.html
│ │ ├── login.html
│ │ └── README.md
│ ├── application-dev.properties
│ └── application.properties
├── mvnw
├── mvnw.cmd
├── pom.xml
└── README.md
|
C'est l'organisation standard d'un projet [Maven], déjà rencontrée dans les exemples : le code [Java] dans [src/main/java], rangé en packages (un package [Java] est un dossier de classes, dont le nom, ici [rdvmedecins.web] par exemple, reprend le chemin) ; tout le reste (configuration, vues, dictionnaires, fichiers statiques) dans [src/main/resources]. À la compilation, [Maven] recopie le contenu de [src/main/resources] dans [target/classes], à côté des classes compilées : l'ensemble forme le classpath de l'application, l'ensemble des dossiers et archives où la machine virtuelle cherche classes et fichiers. C'est pourquoi le code désigne un dictionnaire par [i18n/fr.json], sans le préfixe [src/main/resources]. Le dossier [target], fabriqué par [Maven], n'est pas montré.
Chaque dossier principal contient un fichier [README.md] qui présente ses fichiers. Le tableau suivant met en regard les dossiers des deux versions de l'application :
ASP.NET Core MVC | Spring MVC | Contenu |
[Program.cs] | [RdvMedecinsApplication.java], [config/] | démarrage, configuration |
[Config/] | [config/] | propriétés typées |
[Auth/] | [auth/] | authentification |
[Controllers/] | [controllers/] | contrôleurs |
[Web/], [Models/] | [web/], [models/] | services communs de la couche web, modèles |
[Metier/] | [metier/] | couche métier |
[Repositories/], [Data/] | [repositories/] | couche DAO |
[Entities/], [Domain/] | [entities/], [domain/] | entités, objets de l'agenda |
[Views/] | [templates/] | vues |
[i18n/] | [i18n/] | dictionnaires |
[wwwroot/] | [static/] | fichiers statiques |
[appsettings.json] | [application.properties] | configuration |
[RdvMedecins.csproj] | [pom.xml] | le fichier projet |
La suite commente tous les fichiers, des couches basses (base de données) vers les couches hautes (vues).
11.5. La base de données
La base [dbrdvmedecins2] est créée par le script [database/dbrdvmedecins2.sql] (cf. « Créer la base de données »). C'est, au mot près, le script de la version [ASP.NET Core], précédé d'une ligne [SET NAMES] : les deux applications peuvent travailler sur la même base, et l'une peut remplacer l'autre sans rien y changer. Nous le montrons en deux extraits : la structure, puis les données.
11.5.1. database/dbrdvmedecins2.sql
| -- =========================================================================
-- Base de données [dbrdvmedecins2] - MySQL 8
--
-- Évolution de [dbrdvmedecins] :
-- 1. les tables [medecins] et [clients] reçoivent une colonne [user],
-- clé étrangère vers users(id) : chaque médecin et chaque client est
-- désormais aussi un utilisateur capable de se connecter ;
-- 2. un troisième rôle apparaît : DOCTOR (en plus de ADMIN et USER) ;
-- 3. le script crée une ligne [users] pour chaque médecin (rôle DOCTOR)
-- et pour chaque client (rôle USER) :
-- - login = nom en minuscules
-- - mot de passe = nom en minuscules, haché avec bcrypt (10 tours)
-- - nom affiché = prénom + nom
--
-- 4. contraintes d'unicité, toutes NOMMÉES (l'application reconnaît
-- le nom de la contrainte violée dans le message d'erreur MySQL) :
-- - unq_users_login : un login n'appartient qu'à un seul compte ;
-- - unq_medecins_prenom_nom : deux médecins ne peuvent pas avoir les
-- mêmes prénom et nom ;
-- - unq_clients_prenom_nom : idem pour les clients.
-- Attention : la collation utf8mb4_unicode_ci compare sans tenir compte
-- des majuscules ni des accents - "Hélène DUPONT" et "helene dupont"
-- sont donc considérés comme identiques.
--
-- La table [users] doit maintenant être créée AVANT [medecins] et [clients]
-- (elles la référencent), et supprimée APRÈS elles.
-- =========================================================================
-- le script est écrit en UTF-8
SET NAMES utf8mb4;
-- on repart d'une base vide à chaque exécution du script (pratique en développement)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;
-- ordre de suppression : des tables « filles » vers les tables « mères »
DROP TABLE IF EXISTS rv;
DROP TABLE IF EXISTS creneaux;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS medecins;
DROP TABLE IF EXISTS users;
-- -------------------------------------------------------------------------
-- table [users] : les utilisateurs autorisés à se connecter à l'application
-- -------------------------------------------------------------------------
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
login VARCHAR(30) NOT NULL, -- identifiant de connexion
password VARCHAR(100) NOT NULL, -- mot de passe HACHÉ (bcrypt) - jamais en clair
nom VARCHAR(40) NOT NULL, -- nom affiché une fois connecté
role VARCHAR(10) NOT NULL, -- 'ADMIN', 'DOCTOR' ou 'USER'
-- un login ne peut appartenir qu'à un seul compte
CONSTRAINT unq_users_login UNIQUE (login)
);
-- -------------------------------------------------------------------------
-- table [medecins] : les médecins du cabinet
-- -------------------------------------------------------------------------
CREATE TABLE medecins (
id INT AUTO_INCREMENT PRIMARY KEY, -- clé primaire (identifiant technique)
version INT NOT NULL DEFAULT 0, -- n° de version de la ligne (verrou optimiste)
titre VARCHAR(5) NOT NULL, -- Melle, Mme, Mr
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_medecins_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par médecin, et un médecin par compte
CONSTRAINT unq_medecins_user UNIQUE (`user`),
-- deux médecins ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_medecins_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [clients] : les patients des médecins
-- -------------------------------------------------------------------------
CREATE TABLE clients (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
titre VARCHAR(5) NOT NULL,
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par client, et un client par compte
CONSTRAINT unq_clients_user UNIQUE (`user`),
-- deux clients ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_clients_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [creneaux] : les créneaux horaires de chaque médecin
-- -------------------------------------------------------------------------
CREATE TABLE creneaux (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
id_medecin INT NOT NULL, -- clé étrangère -> medecins(id)
hdebut INT NOT NULL, -- heure de début du créneau
mdebut INT NOT NULL, -- minute de début du créneau
hfin INT NOT NULL, -- heure de fin du créneau
mfin INT NOT NULL, -- minute de fin du créneau
CONSTRAINT fk_creneaux_medecin FOREIGN KEY (id_medecin) REFERENCES medecins(id)
);
-- -------------------------------------------------------------------------
-- table [rv] : les rendez-vous pris par les clients
-- -------------------------------------------------------------------------
CREATE TABLE rv (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
jour DATE NOT NULL, -- jour du rendez-vous
id_creneau INT NOT NULL, -- clé étrangère -> creneaux(id)
id_client INT NOT NULL, -- clé étrangère -> clients(id)
CONSTRAINT fk_rv_creneau FOREIGN KEY (id_creneau) REFERENCES creneaux(id),
CONSTRAINT fk_rv_client FOREIGN KEY (id_client) REFERENCES clients(id),
-- un même créneau, un même jour, ne peut être réservé qu'une seule fois
CONSTRAINT unq1_rv UNIQUE (jour, id_creneau)
);
|
Commentons cette première partie :
- ligne 30 : le script annonce au serveur qu'il est écrit en UTF-8. Sans cette ligne, le client [mysql] de certains systèmes enverrait les caractères accentués (« Hélène ») dans un autre encodage, et ils seraient enregistrés déformés ;
- ligne 33 : la base est créée si elle n'existe pas encore, avec le jeu de caractères [utf8mb4] et la collation [utf8mb4_unicode_ci], comme à l'exemple 28 : les comparaisons de chaînes ignorent majuscules et accents. Cela vaut aussi pour les contraintes d'unicité : [Hélène DUPONT] et [helene dupont] sont, pour [MySQL], deux fois la même personne, et « Admin » le même login que « admin » ;
- lignes 37-41 : le script repart d'une base vide à chaque exécution. Les tables sont supprimées des « filles » vers les « mères » : une table référencée par une clé étrangère ([users], par exemple, référencée par [medecins] et [clients]) ne peut pas être supprimée tant qu'une autre table la référence encore. Les créations se font, elles, dans l'ordre inverse ;
- lignes 46-55 : les comptes des utilisateurs. Chaque table commence par une clé primaire [id], attribuée par la base ([AUTO_INCREMENT]), et par une colonne [version], qui vaut 0 à la création de la ligne ;
- lignes 49-52 : le login, le mot de passe, le nom affiché une fois connecté et le rôle ([ADMIN], [DOCTOR] ou [USER]). La colonne [password] ne contient jamais le mot de passe lui-même, mais son hachage [bcrypt] (cf. exemple 25) : 60 caractères, que la colonne (100 caractères) contient largement ;
- ligne 54 : un login n'appartient qu'à un seul compte. Comme à l'exemple 28, la contrainte est nommée : quand [MySQL] refuse une écriture, son message d'erreur cite le nom de la contrainte violée, et la couche métier s'en sert pour choisir le message à afficher (cf. « La couche métier ») ;
- lignes 60-72 : les médecins : titre, nom, prénom ;
- ligne 62 : la colonne [version] sert au verrou optimiste (cf. exemple 28) : elle est augmentée à chaque modification de la ligne, ce qui permet de détecter qu'un autre utilisateur a modifié la ligne entre sa lecture et son écriture. Ici, c'est [Hibernate] qui l'augmente, grâce à l'annotation [@Version] des entités (cf. « Les entités ») ;
- lignes 66-67 : chaque médecin a un compte : la colonne [user] est une clé étrangère vers [users(id)]. La base refuse une valeur qui ne désigne aucun compte, et refuse de supprimer un compte encore référencé par un médecin. [user] étant un mot réservé de [MySQL], le nom de la colonne est écrit entre apostrophes inverses ;
- ligne 69 : un compte n'appartient qu'à un médecin : la relation entre [medecins] et [users] est une relation « un à un » ;
- ligne 71 : deux médecins ne peuvent pas porter les mêmes prénom et nom. La contrainte porte sur le couple de colonnes : deux médecins peuvent avoir le même nom, s'ils n'ont pas le même prénom. Un médecin et un client peuvent en revanche porter le même nom : ce sont deux tables différentes ;
- lignes 77-89 : les clients (les patients), sur le même modèle : un compte par client (clé étrangère [fk_clients_user], unicité [unq_clients_user]), pas deux clients de mêmes prénom et nom ;
- lignes 94-103 : les créneaux horaires de chaque médecin (clé étrangère [id_medecin]) : heure et minute de début, heure et minute de fin. Un créneau ne porte pas de date : il se répète tous les jours ;
- lignes 108-118 : les rendez-vous : un jour (type [DATE], sans heure), un créneau et un client, désignés par deux clés étrangères ;
- ligne 117 : un même créneau ne peut être réservé qu'une fois par jour. C'est la base elle-même qui garantit cette règle : si deux utilisateurs réservent le même créneau au même instant, le second [INSERT] est refusé, quoi qu'ait vérifié l'application juste avant.
La seconde partie du script insère les données de démonstration :
| -- =========================================================================
-- Données de démonstration
-- =========================================================================
-- les utilisateurs : mots de passe hachés avec bcrypt (10 tours de salage)
-- admin/admin (ADMIN)
-- un compte DOCTOR par médecin, un compte USER par client
-- (le compte de démonstration user/user a disparu : un compte USER est
-- désormais TOUJOURS celui d'un client, qui peut réserver et gérer son compte)
-- (login = mot de passe = nom en minuscules)
INSERT INTO users (login, password, nom, role) VALUES
('admin', '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrateur', 'ADMIN'),
-- médecins
('pelissier', '$2b$10$DnSDOK5ONrdMxTTr1NKCHOphR3WSp5QAyG9S9ueruw9HwlxI.16ba', 'Marie PELISSIER', 'DOCTOR'),
('tatou', '$2b$10$PeovBO.gmJUKP4PglI/fwu4xtWOYSxoM5VTWjTj7uQ4wcd9P9.htS', 'Paul TATOU', 'DOCTOR'),
('dupont', '$2b$10$az7wvPKC1h0EiAxXwu/PU.vYjzmB04rd5B7QnK0mvxp4niYIHVoc.', 'Sophie DUPONT', 'DOCTOR'),
-- clients
('martin', '$2b$10$GT6S5pp.WdGapFw/UmocXesbC6LEjJfZzgatCUyG9Sf4o1iBNlqZW', 'Jean MARTIN', 'USER'),
('durand', '$2b$10$l2gl/zCder8E7Nh4VxGRX.0.SsO3FDiXt6YwqYq3v9nFzgC/jQCy6', 'Alice DURAND', 'USER'),
('bistrou', '$2b$10$naeRp2YUU6c.QDl5itJMAum3Y5aHdUV6jwIcsbp0D.64jPG4F6E9m', 'Brigitte BISTROU', 'USER'),
('german', '$2b$10$tTWxh3ROWF7NjhZOMnWDMOsZcnSWUiwwfy5C3G0.xhccTXALKyLeW', 'Luc GERMAN', 'USER');
-- les médecins : la colonne [user] est retrouvée à partir du login,
-- plutôt que de supposer les valeurs des clés primaires de [users]
INSERT INTO medecins (titre, nom, prenom, `user`) VALUES
('Mme', 'PELISSIER', 'Marie', (SELECT id FROM users WHERE login = 'pelissier')),
('Mr', 'TATOU', 'Paul', (SELECT id FROM users WHERE login = 'tatou')),
('Melle','DUPONT', 'Sophie', (SELECT id FROM users WHERE login = 'dupont'));
INSERT INTO clients (titre, nom, prenom, `user`) VALUES
('Mr', 'MARTIN', 'Jean', (SELECT id FROM users WHERE login = 'martin')),
('Mme', 'DURAND', 'Alice', (SELECT id FROM users WHERE login = 'durand')),
('Melle','BISTROU', 'Brigitte', (SELECT id FROM users WHERE login = 'bistrou')),
('Mr', 'GERMAN', 'Luc', (SELECT id FROM users WHERE login = 'german'));
-- créneaux du docteur PELISSIER (id_medecin = 1) : de 8h00 à 12h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(1, 8, 0, 8, 20), (1, 8, 20, 8, 40), (1, 8, 40, 9, 0),
(1, 9, 0, 9, 20), (1, 9, 20, 9, 40), (1, 9, 40, 10, 0),
(1, 10, 0, 10, 20), (1, 10, 20, 10, 40), (1, 10, 40, 11, 0),
(1, 11, 0, 11, 20), (1, 11, 20, 11, 40), (1, 11, 40, 12, 0);
-- créneaux du docteur TATOU (id_medecin = 2) : de 14h00 à 18h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(2, 14, 0, 14, 20), (2, 14, 20, 14, 40), (2, 14, 40, 15, 0),
(2, 15, 0, 15, 20), (2, 15, 20, 15, 40), (2, 15, 40, 16, 0),
(2, 16, 0, 16, 20), (2, 16, 20, 16, 40), (2, 16, 40, 17, 0),
(2, 17, 0, 17, 20), (2, 17, 20, 17, 40), (2, 17, 40, 18, 0);
-- un rendez-vous déjà pris, pour voir tout de suite un créneau occupé dans l'agenda
INSERT INTO rv (jour, id_creneau, id_client) VALUES
(CURDATE(), 2, 3);
|
- lignes 5-21 : les comptes de démonstration : l'administrateur, trois médecins (rôle [DOCTOR]) et quatre clients (rôle [USER]). Le mot de passe de chacun est son login ([admin] / [admin], [pelissier] / [pelissier]...). Les hachages commencent par [$2b$10$] : l'algorithme [bcrypt], dans sa version [2b], avec un coût de 10 (cf. exemple 25). L'encodeur [BCryptPasswordEncoder] de [Spring Security], réglé sur cette même version, sait les vérifier (cf. « L'authentification ») ;
- lignes 25-28 : la colonne [user] de chaque médecin est retrouvée par une sous-requête sur le login, plutôt que de supposer les identifiants attribués par la base aux comptes ;
- lignes 30-34 : les clients, de la même façon ;
- lignes 36-41 : douze créneaux de 20 minutes, de 8 h 00 à 12 h 00, pour le docteur PELISSIER. Ici, l'identifiant du médecin (1) est écrit en dur : la table [medecins] vient d'être créée, ses identifiants commencent donc à 1 ;
- lignes 43-48 : douze créneaux, de 14 h 00 à 18 h 00, pour le docteur TATOU. Le docteur DUPONT n'a pas de créneau : son agenda est vide ;
- lignes 50-52 : un rendez-vous déjà pris, pour Brigitte BISTROU (client n° 3) sur le deuxième créneau du docteur PELISSIER (8 h 20). [CURDATE()] est la date du jour de l'exécution du script : dès la première connexion, l'agenda du jour montre un créneau occupé.
11.6. La configuration du projet
11.6.1. pom.xml
Le fichier [pom.xml] ([Project Object Model]) décrit le projet à [Maven] : son identité, la version de [Java], les bibliothèques utilisées et la façon de lancer l'application. C'est l'équivalent du fichier [RdvMedecins.csproj] de la version [ASP.NET Core]. Les exemples avaient un [pom.xml] chacun, tous héritiers d'un [pom.xml] commun, placé dans le dossier [exemples] ; l'étude de cas, projet autonome, écrit tout dans le sien.
| <?xml version="1.0" encoding="UTF-8"?>
<!--
=========================================================================
pom.xml - le fichier projet de Maven : identité du projet, version de Java,
bibliothèques utilisées, et plugin qui compile et lance l'application.
Les bibliothèques (dependencies) sont téléchargées depuis Maven Central
lors de la première compilation, et rangées dans le dossier ~/.m2 de
l'utilisateur. Leurs versions ne sont pas écrites ici : elles sont fixées,
toutes compatibles entre elles, par le projet parent de Spring Boot.
=========================================================================
-->
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>cours.springmvc</groupId>
<artifactId>rdvmedecins</artifactId>
<version>1.0</version>
<name>RdvMedecins</name>
<properties>
<!-- Java 21, version LTS (support à long terme) -->
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring MVC et le serveur web Tomcat, embarqué dans l'application -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- les vues Thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- la validation des formulaires (Bean Validation, Hibernate Validator) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Security : authentification par jeton JWT, rôles, hachage bcrypt -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Data JPA : l'ORM Hibernate, les transactions, les dépôts -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- le pilote JDBC de MySQL (il convient aussi pour MariaDB) -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- les outils de développement : redémarrage automatique après une modification -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<!-- mvnw spring-boot:run lance l'application, avec le profil [dev]
(cf. src/main/resources/application-dev.properties) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<profiles>
<profile>dev</profile>
</profiles>
</configuration>
</plugin>
</plugins>
</build>
</project>
|
Commentons ce fichier :
- lignes 18-23 : le projet parent, [spring-boot-starter-parent], dans la version 4.1.1 de [Spring Boot]. Le projet hérite de ses réglages : l'encodage UTF-8 des sources, la configuration du compilateur, celle des plugins et, surtout, les versions de toutes les bibliothèques que [Spring Boot] sait intégrer, choisies pour être compatibles entre elles. C'est pourquoi aucune dépendance, plus bas, n'indique sa version. Pour passer à une version plus récente de [Spring Boot], on ne change que la ligne 21 ;
- ligne 22 : l'élément vide [relativePath] dit à [Maven] de ne pas chercher le parent dans le dossier parent du projet (c'est là que se trouvait le parent des exemples), mais dans les dépôts de bibliothèques ;
- lignes 25-28 : l'identité du projet : le groupe (un nom de domaine inversé, par convention), le nom de l'artefact, sa version, et un nom lisible. L'archive fabriquée par [./mvnw package] s'appelle [artefact-version.jar], soit [rdvmedecins-1.0.jar]. Aucun élément [packaging] n'étant donné, c'est une archive [jar], le cas par défaut ;
- lignes 30-33 : la version de [Java] : le compilateur produit du code pour [Java] 21, et refuse de compiler avec un JDK plus ancien ;
- lignes 35-73 : les dépendances. Presque toutes sont des starters de [Spring Boot] : un starter est une dépendance qui n'apporte pas de code, mais tire avec elle un ensemble cohérent de bibliothèques pour une fonctionnalité donnée. Chaque starter déclenche aussi, au démarrage, l'auto-configuration correspondante (cf. « Le démarrage de l'application ») ;
- lignes 37-40 : [Spring MVC] et le serveur web [Tomcat], intégré à l'application : pas de serveur à installer, l'application est elle-même un serveur web ;
- lignes 42-45 : le moteur de vues [Thymeleaf] (cf. exemple 13) ;
- lignes 47-50 : la validation des formulaires : l'API [Bean Validation] ([@NotBlank], [@Size]...) et son implémentation, [Hibernate Validator] (cf. exemple 19) ;
- lignes 52-55 : [Spring Security], avec son module « resource server » [OAuth2], qui sait vérifier un jeton JWT (cf. exemple 25). Il apporte aussi le hachage [bcrypt] des mots de passe : pas de bibliothèque séparée ;
- lignes 57-60 : [Spring Data JPA], avec l'ORM [Hibernate], le gestionnaire de transactions et le pool de connexions [HikariCP] (cf. exemple 28) ;
- lignes 62-66 : le pilote JDBC de [MySQL], [mysql-connector-j], qui convient aussi pour [MariaDB]. La portée [runtime] (ligne 65) signifie que le code de l'application n'en a pas besoin pour être compilé (il ne parle qu'à JPA et à JDBC) ; le pilote n'est chargé qu'à l'exécution ;
- lignes 68-72 : [Spring Boot DevTools], les outils de développement (redémarrage automatique). [optional] (ligne 71) : la dépendance n'est pas transmise aux projets qui dépendraient de celui-ci, et le plugin de [Spring Boot] ne la met pas dans l'archive exécutable : elle ne sert qu'en développement ;
- lignes 79-87 : le plugin de [Spring Boot] pour [Maven]. C'est lui qui fournit la commande [spring-boot:run], et qui transforme, lors d'un [./mvnw package], l'archive [jar] ordinaire en archive exécutable, contenant toutes les bibliothèques ;
- lignes 82-86 : [spring-boot:run] active le profil [dev], qui ajoute à la configuration le fichier [application-dev.properties] (cf. plus bas). Un profil est un nom qui désigne un jeu de réglages ; celui-ci ne s'applique qu'à [spring-boot:run] : une application lancée par [java -jar] n'a aucun profil actif, sauf si on le demande ([java -jar target/rdvmedecins-1.0.jar --spring.profiles.active=dev]).
11.6.2. Le Maven Wrapper
Le Maven Wrapper permet de construire le projet sans avoir installé [Maven]. Il se compose de trois fichiers :
- [mvnw] (pour [Linux] et [macOS]) et [mvnw.cmd] (pour [Windows]) : deux scripts, fournis par le projet [Apache Maven], qu'on ne modifie pas et que nous ne reproduisons pas ici. Ils lisent le fichier suivant, téléchargent la version de [Maven] qu'il désigne si elle n'est pas déjà présente, la rangent dans le dossier [.m2/wrapper] du dossier personnel de l'utilisateur, puis lui passent la commande : [./mvnw spring-boot:run] exécute [mvn spring-boot:run] avec la bonne version de [Maven] ;
- [.mvn/wrapper/maven-wrapper.properties] :
| wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
- ligne 1 : la version du Maven Wrapper lui-même ;
- ligne 2 : [only-script] : le wrapper se réduit aux scripts [mvnw] et [mvnw.cmd], sans l'archive [maven-wrapper.jar] qu'utilisaient ses anciennes versions ;
- ligne 3 : l'adresse de la distribution de [Maven] à télécharger : la version 3.9.11. Tous ceux qui construisent le projet utilisent ainsi la même version de [Maven], quelle que soit celle éventuellement installée sur leur machine.
Les bibliothèques téléchargées par [Maven] sont rangées, elles, dans le dossier [.m2/repository] du dossier personnel : elles sont partagées par tous les projets, et ne sont téléchargées qu'une fois.
11.6.3. src/main/resources/application.properties
Le fichier [application.properties] tient le rôle du fichier [appsettings.json] de la version [ASP.NET Core]. Chaque ligne associe une valeur à une clé ; les clés sont hiérarchisées par des points ([spring.datasource.url]). [Spring Boot] connaît des centaines de clés, qui règlent ses composants (celles qui commencent par [server.], [spring.]...) ; l'application peut y ajouter les siennes (ici, celles qui commencent par [rdvmedecins.]). Une ligne qui commence par [#] est un commentaire.
| # =========================================================================
# application.properties - la configuration de l'application.
#
# Chaque valeur peut être remplacée sans modifier ce fichier, par une
# variable d'environnement : spring.datasource.password s'écrit alors
# SPRING_DATASOURCE_PASSWORD (majuscules, "." et "-" remplacés par "_").
# =========================================================================
# le port d'écoute : ni 8080 (le port par défaut de Spring Boot, souvent déjà
# occupé), ni 5000 (celui des exemples) - on peut ainsi tout lancer en même temps
server.port=8081
# ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql)
spring.datasource.url=jdbc:mysql://localhost:3306/dbrdvmedecins2
spring.datasource.username=root
spring.datasource.password=
# Hibernate ne crée ni ne modifie les tables : c'est le rôle du script SQL
spring.jpa.hibernate.ddl-auto=none
# la connexion à la base n'est pas gardée ouverte pendant la fabrication de la vue
spring.jpa.open-in-view=false
# ---- les requêtes sont traitées par des threads "virtuels" (Java 21) : un
# thread qui attend la base de données ne bloque plus un thread du système
spring.threads.virtual.enabled=true
# ---- les réponses d'erreur ne contiennent jamais la pile d'appels
spring.web.error.include-stacktrace=never
# ---- l'authentification (cf. config/AuthProprietes.java)
# clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256).
# À changer impérativement en production (longue chaîne aléatoire).
rdvmedecins.auth.jwt-secret=change-moi-en-une-longue-chaine-aleatoire
# durée de validité d'un jeton, en secondes (7200 s = 2 h) ; le cookie qui
# transporte le jeton a la même durée de vie
rdvmedecins.auth.jwt-expires-in-seconds=7200
# true en production (site servi en HTTPS) : le cookie d'authentification
# n'est alors jamais envoyé sur une connexion non chiffrée
rdvmedecins.auth.cookie-secure=false
# ---- la protection contre les robots (cf. config/ProtectionProprietes.java)
# connexion : échecs autorisés pour un même login depuis une même adresse IP...
rdvmedecins.protection.login-max-echecs=5
# ... avant un blocage de cette durée (l'adresse IP entière est aussi bloquée
# si elle cumule 5 fois plus d'échecs, tous logins confondus)
rdvmedecins.protection.login-blocage-minutes=15
# création de compte client : comptes créés au plus par heure et par adresse
# IP. En salle de cours, tous les postes sortent souvent par la même adresse :
# augmenter si besoin.
rdvmedecins.protection.inscription-max-par-heure=10
|
Commentons ce fichier :
- ligne 11 : le port d'écoute du serveur [Tomcat]. Le port par défaut de [Spring Boot] est 8080 ; les exemples écoutaient sur le port 5000 ; l'application écoute sur le port 8081, comme la version [ASP.NET Core] ;
- ligne 14 : l'adresse de la base, sous la forme d'une URL JDBC (cf. exemple 28) : le protocole [jdbc:mysql], puis le serveur, le port et le nom de la base. [Spring Boot] en déduit le pilote à charger ;
- lignes 15-16 : l'utilisateur et le mot de passe de [MySQL], ceux de [Laragon] par défaut : [root], sans mot de passe. La ligne 16 est celle qu'on complète si le compte [root] a un mot de passe ;
- ligne 18 : [Hibernate] sait créer ou modifier lui-même les tables d'après les entités ; ici, il n'y touche pas : la structure de la base est celle du script SQL, partagé avec la version [ASP.NET Core] ;
- ligne 20 : par défaut, [Spring Boot] garde la connexion à la base ouverte jusqu'à la fin de la requête, y compris pendant la fabrication de la vue (le mode « open in view »), et signale ce choix par un avertissement au démarrage. L'application le désactive, comme l'exemple 28 : toutes les données dont une vue a besoin sont lues par la couche métier, dans sa transaction, avant que la vue ne soit fabriquée. Une connexion n'est ainsi jamais retenue plus longtemps que nécessaire ;
- ligne 24 : les requêtes sont traitées par des threads virtuels, une nouveauté de [Java] 21. Un serveur web traite chaque requête dans un thread (un fil d'exécution) ; un thread du système est une ressource coûteuse, et [Tomcat] n'en garde qu'un nombre limité (200 par défaut). Or un thread qui attend la réponse de la base de données ne fait rien, mais reste occupé. Un thread virtuel est un thread léger, géré par la machine virtuelle : pendant qu'il attend, le thread du système qui le portait en exécute un autre. Le code de l'application n'a pas à changer pour en profiter ;
- ligne 27 : les réponses d'erreur ne contiennent jamais la pile d'appels, qui renseignerait un attaquant sur le fonctionnement interne de l'application (cf. chapitre 1) ;
- lignes 32-38 : les clés de l'authentification : la clé secrète qui signe les jetons (au moins 32 caractères, à remplacer impérativement en production par une longue chaîne aléatoire), la durée de vie d'un jeton (deux heures), et l'option [Secure] du cookie qui le transporte. En production, le site est servi en HTTPS, et le cookie ne doit jamais voyager sur une connexion non chiffrée : on y passe l'option à [true] ;
- lignes 42-49 : les seuils de la protection contre les robots (cf. exemple 27) : nombre d'échecs de connexion autorisés et durée du blocage qui suit ; nombre de comptes clients créés par heure depuis une même adresse IP.
Les clés [rdvmedecins.auth.*] et [rdvmedecins.protection.*] sont lues sous forme de records typés (cf. [config/AuthProprietes.java], plus bas). Dans le fichier, les noms composés s'écrivent en minuscules séparées par des tirets ([jwt-secret]) : c'est la convention de [Spring Boot], qui les fait correspondre aux noms [Java] ([jwtSecret]).
[Spring Boot] lit la configuration dans plusieurs sources successives, chacune pouvant remplacer les valeurs des précédentes : [application.properties], puis [application-{profil}.properties] pour chaque profil actif (cf. fichier suivant), puis les variables d'environnement, et enfin les arguments de la ligne de commande ([--server.port=9000]). Le nom d'une variable d'environnement se déduit de la clé : majuscules, [.] et [-] remplacés par [_]. [spring.datasource.password] devient ainsi [SPRING_DATASOURCE_PASSWORD], et [rdvmedecins.auth.jwt-secret] devient [RDVMEDECINS_AUTH_JWT_SECRET] : c'est ainsi qu'on donne un secret à l'application en production, sans l'écrire dans un fichier du projet.
11.6.4. src/main/resources/application-dev.properties
| # =========================================================================
# application-dev.properties - complète application.properties quand le
# profil [dev] est actif (mvnw spring-boot:run l'active, cf. pom.xml) :
# on affiche dans la console les ordres SQL envoyés par Hibernate.
# =========================================================================
spring.jpa.show-sql=true
|
- ligne 6 : quand le profil [dev] est actif, [Spring Boot] lit, après [application.properties], le fichier [application-dev.properties], et fusionne les deux : les clés présentes dans le second remplacent celles du premier, les autres sont conservées. En développement, on ajoute donc la clé [spring.jpa.show-sql] : [Hibernate] écrit dans la console chaque ordre SQL qu'il envoie à la base, précédé de [Hibernate:]. C'est l'équivalent du fichier [appsettings.Development.json] de la version [ASP.NET Core], le profil jouant le rôle de l'environnement [Development]. Nous avons vu que c'est [pom.xml] qui active ce profil pour [spring-boot:run].
11.6.5. config/AuthProprietes.java
| // =========================================================================
// config/AuthProprietes.java
// -------------------------------------------------------------------------
// Les propriétés [rdvmedecins.auth.*] de application.properties, sous forme
// d'un record typé. Spring Boot fait la correspondance des noms :
// rdvmedecins.auth.jwt-secret -> jwtSecret
// rdvmedecins.auth.jwt-expires-in-seconds -> jwtExpiresInSeconds
// rdvmedecins.auth.cookie-secure -> cookieSecure
// et convertit les valeurs dans le type des composants (int, boolean).
// Une classe qui en a besoin reçoit ce record par injection de dépendances :
// on évite ainsi de lire des chaînes de caractères un peu partout.
// =========================================================================
package rdvmedecins.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("rdvmedecins.auth")
public record AuthProprietes(
// clé secrète utilisée pour signer / vérifier les jetons JWT. L'algorithme
// HMAC-SHA256 exige une clé d'au moins 256 bits, soit 32 octets.
String jwtSecret,
// durée de validité d'un jeton, en secondes ; le cookie qui le transporte
// a la même durée de vie
int jwtExpiresInSeconds,
// true en production (HTTPS) : le cookie d'authentification n'est jamais
// envoyé sur une connexion non chiffrée
boolean cookieSecure) {
}
|
Commentons ce code :
- ligne 18 : les clés [rdvmedecins.auth.*] de [application.properties], sous la forme d'un record : une classe dont les champs, déclarés entre parenthèses (les « composants » du record), sont fixés une fois pour toutes par le constructeur. Le compilateur écrit lui-même ce constructeur, ainsi qu'une méthode de lecture par composant, du même nom : [jwtSecret()], [jwtExpiresInSeconds()], [cookieSecure()] ;
- ligne 17 : l'annotation [@ConfigurationProperties] indique le préfixe des clés. Au démarrage, [Spring Boot] crée le record en appelant son constructeur avec les valeurs des clés [rdvmedecins.auth.jwt-secret], [rdvmedecins.auth.jwt-expires-in-seconds] et [rdvmedecins.auth.cookie-secure], converties dans le type de chaque composant (["7200"] devient l'entier 7200, [false] le booléen [false]). Une valeur qui ne peut pas être convertie (un texte là où un nombre est attendu) arrête l'application au démarrage, avec un message qui désigne la clé fautive. Une clé absente donne la valeur par défaut du type : [null], 0 ou [false] ;
- lignes 21-27 : les trois composants. Le record devient un bean, qu'une classe qui en a besoin reçoit par son constructeur (injection de dépendances, cf. exemple 06) : c'est ce que font [config/SecuriteConfig.java] et [auth/AuthService.java] (cf. « L'authentification »). On évite ainsi de lire des chaînes de caractères un peu partout : les noms sont vérifiés par le compilateur, et les valeurs sont déjà converties.
C'est la technique de l'exemple 25, qui déclarait son record par l'annotation [@EnableConfigurationProperties(JwtProprietes.class)]. L'étude de cas en a deux, et laisse [Spring Boot] les trouver lui-même, grâce à l'annotation [@ConfigurationPropertiesScan] de la classe principale (cf. « Le démarrage de l'application »).
11.6.6. config/ProtectionProprietes.java
| // =========================================================================
// config/ProtectionProprietes.java
// -------------------------------------------------------------------------
// Les propriétés [rdvmedecins.protection.*] : les seuils de la protection
// contre les robots (cf. auth/LimiteurTentativesService.java).
// =========================================================================
package rdvmedecins.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("rdvmedecins.protection")
public record ProtectionProprietes(
// connexion : nombre d'échecs (mauvais captcha ou mot de passe) autorisés
// pour un même login depuis une même adresse IP, avant blocage de ce couple...
int loginMaxEchecs,
// ... pendant cette durée (une adresse IP est aussi bloquée si elle cumule
// 5 fois plus d'échecs, tous logins confondus)
int loginBlocageMinutes,
// création de compte client : nombre maximal de comptes créés par heure
// depuis une même adresse IP
int inscriptionMaxParHeure) {
}
|
- lignes 11-21 : les clés [rdvmedecins.protection.*], sur le même modèle. Le record est injecté dans [auth/LimiteurTentativesService.java], qui y lit ses seuils (cf. « L'authentification »).
11.7. Le démarrage de l'application
La version [ASP.NET Core] réunissait dans [Program.cs] tout ce qui concernait le démarrage : l'enregistrement de chaque service, puis la construction, dans un ordre précis, du pipeline des middlewares. Avec [Spring Boot], ce travail est réparti : la classe principale se contente de démarrer [Spring Boot] ; celui-ci configure lui-même l'essentiel, d'après les bibliothèques présentes et [application.properties] ; et chaque aspect que l'application veut régler autrement a sa propre classe de configuration, dans le dossier [config].
11.7.1. RdvMedecinsApplication.java
| // =========================================================================
// RdvMedecinsApplication.java
// -------------------------------------------------------------------------
// Le point d'entrée de l'application : la méthode main démarre Spring Boot.
//
// @SpringBootApplication réunit trois annotations :
// - @Configuration : la classe peut déclarer des beans (@Bean) ;
// - @EnableAutoConfiguration : Spring Boot configure lui-même ce que les
// bibliothèques présentes réclament (Spring MVC, Thymeleaf, JPA, Spring
// Security...), d'après application.properties ;
// - @ComponentScan : Spring cherche les classes annotées (@Controller,
// @Service, @Component, @Repository...) dans le package [rdvmedecins] et
// ses sous-packages, et en fait des beans, qu'il fournit à ceux qui en
// ont besoin (injection de dépendances).
//
// @ConfigurationPropertiesScan : les records annotés @ConfigurationProperties
// (package config) reçoivent les valeurs de application.properties.
//
// Pas d'équivalent ici au "pipeline des middlewares" du Program.cs d'ASP.NET
// Core : l'ordre des filtres est fixé par Spring Boot et Spring Security, et
// chaque aspect est configuré dans sa propre classe (config/SecuriteConfig,
// config/WebConfig...).
// =========================================================================
package rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class RdvMedecinsApplication {
public static void main(String[] args) {
SpringApplication.run(RdvMedecinsApplication.class, args);
}
}
|
Commentons ce code :
- lignes 34-36 : la méthode [main], point d'entrée de tout programme [Java], comme dans les exemples. [SpringApplication.run] crée le conteneur de [Spring] (le « contexte d'application », qui fabrique et garde tous les beans), démarre le serveur [Tomcat], puis rend la main ; le programme continue de tourner tant que le serveur écoute. La classe passée en paramètre sert de point de départ à la configuration ;
- ligne 30 : l'annotation [@SpringBootApplication], que portaient déjà tous les exemples, réunit les trois annotations que détaille le commentaire du fichier (lignes 6-14). [@ComponentScan] fait parcourir à [Spring] le package [rdvmedecins] et tous ses sous-packages : chaque classe annotée [@Controller], [@Service], [@Component] (ou [@Configuration], [@ControllerAdvice]...) y devient un bean. Les interfaces de dépôt [Spring Data JPA] sont trouvées de la même façon, dans les mêmes packages, par l'auto-configuration de [Spring Data JPA] (cf. ci-dessous). Il n'y a donc pas, comme dans [Program.cs], de liste des services à tenir à jour : ajouter une classe annotée dans l'un de ces packages suffit. C'est pour cela que la classe principale est placée dans le package racine, au-dessus de tous les autres ;
- ligne 31 : [@ConfigurationPropertiesScan] fait de même pour les records annotés [@ConfigurationProperties] (cf. [config/AuthProprietes.java]).
Les beans de l'application sont tous des singletons : une seule instance, partagée par toutes les requêtes. La version [ASP.NET Core] distinguait trois durées de vie ([Singleton], [Scoped], [Transient]) parce que sa couche DAO dépendait d'un [DbContext] créé pour chaque requête. Avec [Spring], les dépôts et la couche métier ne dépendent d'aucune requête : c'est [Spring] qui fournit à chaque traitement une connexion prise dans le pool de connexions (un stock de connexions ouvertes, réutilisées d'une requête à l'autre) et, pour les écritures de la couche métier, sa transaction. Ce point est développé dans les sections consacrées à la couche DAO et à la couche métier.
[@EnableAutoConfiguration], contenue dans [@SpringBootApplication], déclenche l'auto-configuration : au démarrage, [Spring Boot] examine les bibliothèques présentes dans le classpath et les beans déjà déclarés par l'application, et crée lui-même les beans qui manquent, réglés d'après [application.properties]. La console du démarrage, montrée plus haut, permet de suivre ce travail :
Ce qui est configuré | Présent grâce à | Traces dans la console |
le serveur [Tomcat], sur le port [server.port] | [spring-boot-starter-webmvc] | [Tomcat initialized with port 8081], puis [Tomcat started on port 8081] |
la servlet [DispatcherServlet], le contrôleur frontal de [Spring MVC], qui reçoit toutes les requêtes et les répartit entre les actions | [spring-boot-starter-webmvc] | [Initializing Servlet 'dispatcherServlet'], à la première requête |
les fichiers statiques du dossier [static/], servis tels quels ([/css/styles.css]...) | [spring-boot-starter-webmvc] | aucune |
le moteur [Thymeleaf] : la vue [agenda] est le fichier [templates/agenda.html] | [spring-boot-starter-thymeleaf] | aucune |
le validateur des formulaires ([Hibernate Validator]) | [spring-boot-starter-validation] | aucune |
la source de données ([DataSource]) : un pool de connexions [HikariCP] vers l'URL [spring.datasource.url] | [spring-boot-starter-data-jpa], [mysql-connector-j] | [HikariPool-1 - Start completed] |
[Hibernate] (la « fabrique » des [EntityManager] de [JPA]), le gestionnaire de transactions et le bean [TransactionTemplate] qu'utilise la couche métier | [spring-boot-starter-data-jpa] | [Hibernate ORM core version 7.4.5.Final], [Database info], [Initialized JPA EntityManagerFactory] |
une implémentation de chacune des cinq interfaces de dépôt du dossier [repositories/] (la sixième, [PersonneRepository], modèle commun aux médecins et aux clients, est marquée [@NoRepositoryBean] : [Spring Data JPA] ne l'implémente pas) | [spring-boot-starter-data-jpa] | [Found 5 JPA repository interfaces] |
le redémarrage automatique, et la désactivation du cache des vues [Thymeleaf] | [spring-boot-devtools] | [Devtools property defaults active!], [restartedMain] |
Commentons quelques lignes de cette console :
- [The following 1 profile is active: "dev"] : le profil activé par [pom.xml] ; [application-dev.properties] est donc lu ;
- [restartedMain] est le nom du thread qui démarre l'application : celui de [Spring Boot DevTools], qui la relancera après chaque recompilation. Lancée par [java -jar], sans DevTools, l'application démarre dans le thread [main] ;
- l'avertissement [HHH000511] n'apparaît qu'avec [MariaDB] (cf. « Les problèmes fréquents ») ;
- les deux lignes [Hibernate: select ...] sont les requêtes du chargement du cache (cf. plus bas), affichées grâce au profil [dev]. On y lit la jointure qu'[Hibernate] a déduite des entités, pour lire chaque médecin avec son compte [users] ;
- [Tomcat started on port 8081] vient après ces requêtes : le serveur n'accepte les requêtes qu'une fois tous les beans créés, cache compris ;
- les trois dernières lignes n'apparaissent qu'à la première requête : [Spring Boot] n'initialise la [DispatcherServlet] qu'au moment où l'on en a besoin. Leur thread, [tomcat-handler-0] (dont la console ne garde que la fin du nom), est un thread virtuel (cf. [application.properties]).
L'auto-configuration s'efface devant l'application : elle ne crée un bean que si l'application n'en a pas déclaré un du même rôle. C'est ainsi que l'application remplace les choix par défaut qui ne lui conviennent pas :
- la sécurité : sans configuration, [Spring Boot] protégerait toutes les pages par un formulaire de connexion et un mot de passe généré au démarrage ; [config/SecuriteConfig.java] déclare sa propre chaîne de filtres et son propre décodeur de jetons JWT (cf. « L'authentification ») ;
- les traductions : le bean [messageSource], que [Thymeleaf] utilise pour ses expressions [#{...}], est la classe [web/Traducteur.java], qui lit les dictionnaires JSON (cf. exemple 22 et « La couche web : les services communs ») ;
- la page d'erreur : les erreurs (400, 403, 404, 500...) sont confiées par le serveur à l'adresse [/error]. [Spring Boot] y place d'ordinaire son propre contrôleur ; [controllers/ErreurController.java] prend sa place (cf. exemple 24 et « La couche web : les contrôleurs ») ;
- la langue de chaque requête et les attributs flash : ce sont les deux beans de [config/WebConfig.java], commenté ci-dessous.
Il n'y a pas non plus, comme dans [Program.cs], de pipeline de middlewares à ordonner : chaque requête traverse la chaîne des filtres de [Spring Security], dont l'ordre est fixé par [Spring Security] lui-même, avant d'atteindre la [DispatcherServlet], qui choisit l'action d'après les annotations des contrôleurs (cf. exemple 24).
11.7.3. config/WebConfig.java
| // =========================================================================
// config/WebConfig.java
// -------------------------------------------------------------------------
// Les compléments à la configuration de Spring MVC :
// - la langue de chaque requête, lue dans un cookie (cf. exemple 22) ;
// - les attributs flash, rangés dans un cookie (cf. web/CookieFlashMapManager).
// Les fichiers statiques du dossier src/main/resources/static (css/, js/,
// lib/bootstrap/) sont servis par Spring Boot sans configuration.
// =========================================================================
package rdvmedecins.config;
import java.time.Duration;
import java.util.Locale;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.FlashMapManager;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import rdvmedecins.web.CookieFlashMapManager;
@Configuration
public class WebConfig {
// la langue de chaque requête : celle du cookie [rdvmedecins_langue],
// déposé par LangueController ; le français sinon. Le bean doit s'appeler
// [localeResolver] : c'est sous ce nom que Spring MVC le cherche.
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("rdvmedecins_langue");
resolver.setDefaultLocale(Locale.FRENCH);
resolver.setCookieMaxAge(Duration.ofDays(365));
resolver.setCookieHttpOnly(true);
resolver.setCookieSameSite("Lax");
return resolver;
}
// les attributs flash dans un cookie, et non dans la session HTTP. Le bean
// doit s'appeler [flashMapManager] : il remplace alors celui de Spring MVC.
@Bean
public FlashMapManager flashMapManager() {
return new CookieFlashMapManager();
}
}
|
Commentons ce code :
- ligne 23 : une classe annotée [@Configuration] déclare des beans par des méthodes annotées [@Bean] (cf. exemple 06). Au démarrage, [Spring] appelle chacune de ces méthodes une fois, et garde l'objet rendu comme bean. Le bean porte le nom de la méthode ;
- ligne 30 : le [LocaleResolver] détermine la langue ([Locale]) de chaque requête. [Spring MVC] le cherche sous le nom [localeResolver] : c'est le nom de la méthode qui en fait le remplaçant de celui de [Spring Boot], lequel suivrait la langue préférée du navigateur ;
- ligne 31 : la langue est lue dans le cookie [rdvmedecins_langue], comme à l'exemple 22. Ce cookie est écrit par le même objet, quand [controllers/LangueController.java] lui transmet la langue choisie (boutons FR / EN) ;
- ligne 32 : sans cookie, la langue est le français ;
- lignes 33-35 : le cookie vit un an ; il est [HttpOnly] (le script de la page ne peut pas le lire), et [SameSite=Lax] (le navigateur ne l'envoie pas avec les requêtes émises par un autre site, sauf pour une simple navigation vers l'application) ;
- lignes 41-44 : le [FlashMapManager] conserve les attributs flash entre un POST et la redirection qui le suit (le schéma Post / Redirect / Get, cf. exemple 20). Celui de [Spring MVC] les range dans la session HTTP ; or l'application est sans état : elle ne garde aucune donnée d'un utilisateur entre deux requêtes, l'utilisateur étant désigné par le jeton JWT de son cookie. Le bean nommé [flashMapManager] remplace donc celui de [Spring MVC] par la classe [web/CookieFlashMapManager.java], qui range les attributs flash dans un cookie (cf. « La couche web : les services communs »).
11.7.4. Le chargement du cache au démarrage
Les listes des médecins et des clients sont affichées sur presque toutes les pages (listes déroulantes de l'agenda, pages d'administration). Plutôt que de les relire dans la base à chaque requête, l'application les garde en mémoire, dans le bean [web/ApplicationModel.java], par lequel passent tous les contrôleurs. Ce bean les recharge après chaque ajout, modification ou suppression d'un médecin ou d'un client.
Le premier chargement a lieu au démarrage, dans une méthode de ce bean annotée [@PostConstruct]. Cette annotation demande à [Spring] d'appeler la méthode une fois l'objet créé et ses dépendances injectées (ici, la couche métier) : pendant le démarrage, donc, avant que le serveur n'accepte la première requête. Ce sont les deux requêtes [Hibernate: select ...] de la console. Une erreur de lecture n'est pas interceptée : l'application s'arrête aussitôt, avec l'erreur dans la console. Une base inaccessible (URL erronée, serveur [MySQL] arrêté) arrête d'ailleurs l'application plus tôt encore, dès l'initialisation d'[Hibernate] (cf. « Les problèmes fréquents »).
La version [ASP.NET Core] devait, pour ce chargement, créer explicitement une « portée » dans [Program.cs], car sa façade dépendait du [DbContext] d'une requête. Rien de tel ici : [ApplicationModel] est un singleton, comme la couche métier qu'il utilise. Le fichier est commenté dans la section « La couche web : les services communs ».
11.7.5. Du Program.cs d'ASP.NET Core à Spring Boot
Pour le lecteur qui connaît la version [ASP.NET Core], voici où se retrouve chaque partie de [Program.cs] :
Program.cs (ASP.NET Core) | Spring Boot |
[WebApplication.CreateBuilder(args)] ... [app.Run()] | [SpringApplication.run(...)] ([RdvMedecinsApplication.java]) |
[builder.Services.AddScoped<...>()], un appel par service | [@ComponentScan] : les classes annotées [@Service], [@Component], [@Controller]... ; les interfaces de dépôt (auto-configuration de [Spring Data JPA]) ; les méthodes [@Bean] des classes [@Configuration] |
[Configure<AuthOptions>(...)] et [IOptions<AuthOptions>] | records [@ConfigurationProperties] et [@ConfigurationPropertiesScan] |
[AddDbContext] et [UseMySql] | auto-configuration de la source de données et d'[Hibernate], d'après [spring.datasource.*] |
[AddControllersWithViews] | auto-configuration de la [DispatcherServlet] et de [Thymeleaf] |
[AddAuthentificationJwt], [UseAuthentication], [UseAuthorization] | [config/SecuriteConfig.java] (chaîne de filtres de [Spring Security]) |
[UseStaticFiles] (dossier [wwwroot]) | automatique (dossier [static]) |
[UseRequestLocalization] et [CookieRequestCultureProvider] | bean [localeResolver] ([CookieLocaleResolver], [config/WebConfig.java]) |
[TempData] (cookie) | bean [flashMapManager] ([CookieFlashMapManager], [config/WebConfig.java]) |
[UseExceptionHandler], [UseStatusCodePagesWithReExecute] | l'adresse [/error], traitée par [controllers/ErreurController.java] |
[CreateScope()] puis [ChargerAsync()] avant [app.Run()] | méthode [@PostConstruct] de [web/ApplicationModel.java] |
[appsettings.Development.json] et [ASPNETCORE_ENVIRONMENT] | [application-dev.properties] et le profil [dev] |
[dotnet watch] | [Spring Boot DevTools] |
11.8. Les entités
Les entités [JPA] font correspondre les tables de la base et des classes [Java] (cf. exemple 28). Chaque classe entité est annotée [@Entity] ; ses champs sont associés aux colonnes par des annotations ([@Column], [@Id], [@JoinColumn]...) du paquetage [jakarta.persistence]. Ces annotations sont celles de la norme [JPA] (Jakarta Persistence) ; [Hibernate], l'implémentation de la norme utilisée par [Spring Boot], les lit au démarrage de l'application pour savoir quelle classe va dans quelle table et quel champ dans quelle colonne. La base n'est pas créée par [Hibernate], mais par le script SQL [database/dbrdvmedecins2.sql] (cf. la section « La base de données ») : les entités doivent simplement décrire fidèlement les tables qui existent déjà.
Les entités de l'application sont réparties dans le dossier [entities] :
Fichier | Table | Rôle |
[AbstractEntity.java] | - | les champs communs [id] et [version] (verrou optimiste) |
[Personne.java] | - | les champs communs aux médecins et aux clients : [titre], [nom], [prenom] et le compte utilisateur |
[Medecin.java] | [medecins] | un médecin |
[Client.java] | [clients] | un patient |
[Creneau.java] | [creneaux] | un créneau horaire d'un médecin |
[Rv.java] | [rv] | un rendez-vous |
[User.java] | [users] | un compte utilisateur |
[Role.java] | - | l'énumération des trois rôles |
11.8.1. entities/AbstractEntity.java
| // =========================================================================
// entities/AbstractEntity.java
// -------------------------------------------------------------------------
// Les deux champs communs à toutes les entités : l'identifiant [id] et le
// numéro de version [version] (verrou optimiste).
//
// @MappedSuperclass : cette classe n'a pas de table ; ses champs sont ajoutés
// aux tables des classes filles (Medecin, Client, Creneau, Rv, User).
//
// Les annotations @Id, @Column, @Version... viennent de la norme JPA (Jakarta
// Persistence, package jakarta.persistence) : Hibernate les lit au démarrage
// pour savoir quelle classe va dans quelle table, quel champ dans quelle colonne.
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
@MappedSuperclass
public abstract class AbstractEntity {
// la clé primaire, générée par la base (colonne AUTO_INCREMENT) : Hibernate
// relit sa valeur après chaque INSERT. Integer (et non int) : une entité
// pas encore enregistrée a un id null.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
// @Version : le verrou optimiste. Chaque UPDATE envoyé par Hibernate porte
// la condition "WHERE id = ... AND version = <valeur lue>", et écrit la
// version augmentée de 1. Si quelqu'un a modifié la ligne entre-temps,
// aucune ligne n'est mise à jour : Hibernate lève une exception
// (OptimisticLockException, que Spring traduit en
// ObjectOptimisticLockingFailureException, cf. metier/MetierService.java).
@Version
@Column(name = "version")
private Integer version;
public Integer getId() {
return id;
}
public Integer getVersion() {
return version;
}
}
|
- ligne 23 : [@MappedSuperclass] désigne une classe mère sans table : elle ne correspond à aucune table, mais ses champs et leurs annotations sont ajoutés à ceux de ses classes filles. Toutes les entités de l'application en héritent, et ont donc un identifiant et une version. La classe est de plus [abstract] : on ne crée jamais d'objet [AbstractEntity] ;
- lignes 29-32 : la clé primaire ([@Id]), colonne [id]. [@GeneratedValue(strategy = GenerationType.IDENTITY)] indique que la valeur est fabriquée par la base (colonne [AUTO_INCREMENT]) : [Hibernate] envoie l'[INSERT] sans identifiant, puis relit celui que la base a attribué. Conséquence à retenir pour la suite : l'[INSERT] d'une nouvelle entité part immédiatement, dès l'appel à [save], car [Hibernate] a besoin de l'identifiant tout de suite. Le type est [Integer], et non [int] : un [Integer] peut valoir [null], ce qui est le cas d'une entité pas encore enregistrée ;
- lignes 40-42 : la version de la ligne, pour le verrou optimiste (cf. exemple 28). Avec [@Version], [Hibernate] fait tout le travail : chaque [UPDATE] qu'il envoie porte la condition [WHERE id = ... AND version = <valeur lue>] et écrit la version augmentée de 1. Si la ligne a été modifiée entre-temps par quelqu'un d'autre, aucune ligne n'est mise à jour et [Hibernate] lève une exception, que [Spring] traduit en [ObjectOptimisticLockingFailureException] (cf. « La couche métier »). C'est une différence avec [Entity Framework Core], où l'application devait augmenter elle-même la version avant chaque enregistrement ;
- lignes 44-50 : des accesseurs en lecture seulement. Il n'y a pas de méthode [setId] ni [setVersion] : ces valeurs ne sont écrites que par [Hibernate]. Comme l'annotation [@Id] est placée sur un champ (et non sur une méthode [getId]), [Hibernate] lit et écrit directement les champs, même privés, sans passer par les accesseurs. On parle d'accès par champ.
11.8.2. entities/Personne.java
| // =========================================================================
// entities/Personne.java
// -------------------------------------------------------------------------
// Classe mère (sans table) des entités [Medecin] et [Client] : un titre, un
// nom, un prénom - et le compte utilisateur associé (colonne [user]).
//
// Une relation déclarée dans une @MappedSuperclass est héritée par les
// classes filles exactement comme les colonnes : la relation [user] existe
// donc dans la table [medecins] ET dans la table [clients].
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Column;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.OneToOne;
@MappedSuperclass
public abstract class Personne extends AbstractEntity {
@Column(name = "titre", length = 5)
private String titre; // Melle, Mme, Mr
@Column(name = "nom", length = 20)
private String nom;
@Column(name = "prenom", length = 20)
private String prenom;
// -----------------------------------------------------------------------
// La relation vers le compte utilisateur : un médecin (un client) a UN
// compte, et un compte appartient à UNE personne (colonne [user] UNIQUE).
// - @OneToOne : relation "un pour un" ;
// - fetch = LAZY : le User n'est lu en base que si on le demande (dans la
// requête, cf. @EntityGraph dans repositories/, ou en accédant au champ
// pendant une transaction) ;
// - @JoinColumn : la colonne de la clé étrangère. Les accents graves
// (`user`) demandent à Hibernate de "protéger" ce nom, qui est un mot
// réservé de MySQL.
// -----------------------------------------------------------------------
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "`user`")
private User user;
// la même colonne, lue comme un simple nombre : on connaît ainsi l'id du
// compte sans charger le User. insertable / updatable = false : c'est le
// champ [user] ci-dessus qui écrit la colonne, celui-ci ne fait que la lire.
@Column(name = "`user`", insertable = false, updatable = false)
private Integer idUser;
public String getTitre() { return titre; }
public void setTitre(String titre) { this.titre = titre; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public String getPrenom() { return prenom; }
public void setPrenom(String prenom) { this.prenom = prenom; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public Integer getIdUser() { return idUser; }
}
|
- lignes 19-20 : la classe mère, sans table elle aussi ([@MappedSuperclass]), de [Medecin] et [Client], elle-même fille de [AbstractEntity]. Médecins et clients ont les mêmes colonnes : on les écrit une seule fois ;
- lignes 22-29 : le titre (Mme, Mr...), le nom et le prénom. L'attribut [length] de [@Column] donne la taille de la colonne ; il ne sert en fait que si [Hibernate] crée lui-même les tables, ce qui n'est pas le cas ici (cf. la section « JPA, Hibernate et Spring Data ») : ce n'est pas une règle de validation, seulement une documentation fidèle du script SQL ;
- lignes 42-44 : la relation vers le compte utilisateur, sous la forme d'un champ de type [User] (l'objet lui-même, et non son identifiant). [@OneToOne] déclare une relation « un pour un » : un médecin (un client) a un compte, et un compte appartient à au plus une personne (la colonne [user] est [UNIQUE] dans le script SQL). [@JoinColumn] nomme la colonne qui contient la clé étrangère. [fetch = FetchType.LAZY] demande un chargement paresseux : quand [Hibernate] lit une personne, il ne lit pas son compte ; le champ [user] reçoit à la place un mandataire (un *proxy*), un objet qui se fait passer pour le [User] et qui ne lira la ligne de la table [users] qu'au premier appel d'une de ses méthodes, et seulement si la transaction est encore ouverte. Pour obtenir la personne avec son compte, la requête doit le demander (cf. « La couche DAO ») ;
- ligne 43 : [user] est un mot réservé de [MySQL] : écrit tel quel dans une requête SQL, il provoquerait une erreur de syntaxe. Les accents graves qui entourent le nom dans [@JoinColumn] demandent à [Hibernate] de protéger ce nom : il l'écrira entre accents graves dans chaque ordre SQL, comme le fait le script SQL ;
- lignes 49-50 : la même colonne [user], lue cette fois comme un simple nombre. On connaît ainsi l'identifiant du compte sans charger le compte lui-même : la couche métier s'en sert pour supprimer le compte d'une personne. Une colonne ne peut être écrite que par un seul champ : [insertable = false, updatable = false] indique que celui-ci ne fait que la lire, et que c'est le champ [user] qui l'écrit (dans les [INSERT] et les [UPDATE]) ;
- lignes 52-64 : les accesseurs. Une classe dont les champs privés sont lus par des méthodes [getXxx] et écrits par des méthodes [setXxx] suit la convention JavaBean ; les vues [Thymeleaf] s'appuient sur cette convention ([${medecin.nom}] appelle [getNom()]). Il n'y a pas de [setIdUser] : le champ [idUser] est en lecture seule.
Voici, relevée dans la console de l'application, la requête SQL qu'envoie [Hibernate] pour lire un médecin et son compte (cf. [findWithUserById] dans la couche DAO). On y voit la colonne [user] entourée de ses accents graves, et la jointure avec la table [users] :
| Hibernate: select m1_0.id,m1_0.`user`,m1_0.nom,m1_0.prenom,m1_0.titre,u1_0.id,u1_0.login,u1_0.nom,u1_0.password,u1_0.role,u1_0.version,m1_0.version from medecins m1_0 left join users u1_0 on u1_0.id=m1_0.`user` where m1_0.id=?
|
La colonne [user] de la table [medecins] (alias [m1_0]) n'est lue qu'une fois, bien que deux champs lui correspondent : [Hibernate] remplit avec elle le champ [idUser], et relie le champ [user] au compte lu par la jointure. Le [?] est un paramètre de la requête (l'identifiant du médecin), transmis séparément à la base.
11.8.3. entities/Medecin.java
| // =========================================================================
// entities/Medecin.java
// -------------------------------------------------------------------------
// Un médecin du cabinet (table [medecins]).
//
// Contrainte d'unicité SQL : unq_medecins_prenom_nom (deux médecins ne
// peuvent pas porter les mêmes prénom et nom) - créée par le script SQL,
// reconnue par son nom dans la couche métier.
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
// @Entity : une classe dont les objets sont les lignes d'une table ;
// @Table : le nom de cette table
@Entity
@Table(name = "medecins")
public class Medecin extends Personne {
}
|
- ligne 17 : [@Entity] fait de la classe une entité : ses objets sont des lignes d'une table ;
- ligne 18 : [@Table] nomme cette table, [medecins]. Sans elle, [Hibernate] chercherait une table du nom de la classe ;
- ligne 19 : la classe hérite de [Personne] tous ses champs, et de [AbstractEntity] l'identifiant et la version. Elle n'a pas de constructeur écrit : le compilateur [Java] lui donne alors un constructeur par défaut, public et sans paramètre, exactement ce qu'exige [JPA] (cf. [Rv.java]) et ce qu'utilisera la couche métier ([Medecin::new]). La contrainte d'unicité [unq_medecins_prenom_nom], citée dans le commentaire, est créée par le script SQL ; la couche métier la reconnaît à son nom.
Contrairement à la version [ASP.NET Core], [Medecin] ne déclare pas la relation inverse vers ses créneaux (une liste [@OneToMany]) : l'application ne s'en servirait pas, elle lit les créneaux d'un médecin par la couche DAO.
11.8.4. entities/Client.java
| // =========================================================================
// entities/Client.java
// -------------------------------------------------------------------------
// Attention : [Client] désigne ici un PATIENT du cabinet médical, pas un
// client HTTP ! C'est le vocabulaire du domaine métier original, conservé
// tel quel (table [clients]).
//
// Contrainte d'unicité SQL : unq_clients_prenom_nom (cf. Medecin.java).
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "clients")
public class Client extends Personne {
}
|
Un client, c'est-à-dire un patient du cabinet (et non un client HTTP) : une [Personne] de la table [clients] (lignes 15-17), sans champ supplémentaire.
11.8.5. entities/Creneau.java
| // =========================================================================
// entities/Creneau.java
// -------------------------------------------------------------------------
// Un créneau horaire d'un médecin (table [creneaux]) : 8h00 - 8h20, etc.
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
@Column(name = "hdebut")
private int hdebut; // heure de début du créneau
@Column(name = "mdebut")
private int mdebut; // minute de début du créneau
@Column(name = "hfin")
private int hfin; // heure de fin du créneau
@Column(name = "mfin")
private int mfin; // minute de fin du créneau
// plusieurs créneaux pour un même médecin : @ManyToOne ("plusieurs vers un"),
// clé étrangère [id_medecin]. Même technique que Personne.user / idUser.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
@Column(name = "id_medecin", insertable = false, updatable = false)
private Integer idMedecin;
public int getHdebut() { return hdebut; }
public int getMdebut() { return mdebut; }
public int getHfin() { return hfin; }
public int getMfin() { return mfin; }
public Medecin getMedecin() { return medecin; }
public Integer getIdMedecin() { return idMedecin; }
}
|
- lignes 19-29 : l'heure et la minute de début et de fin du créneau, de type [int] : ces colonnes sont déclarées [NOT NULL] dans la base, un type primitif, qui ne peut pas valoir [null], leur convient ;
- lignes 33-35 : la relation « plusieurs à un » vers le médecin ([@ManyToOne]) : plusieurs créneaux pour un médecin, clé étrangère dans la colonne [id_medecin] ; le médecin est chargé paresseusement ([LAZY]). Même technique que pour le compte d'une personne ;
- lignes 37-38 : la même colonne [id_medecin], en lecture seule, sous la forme d'un nombre. C'est sur ce champ que porteront les requêtes « les créneaux du médecin n° 2 », sans avoir à charger le médecin ;
- lignes 40-45 : des accesseurs en lecture seulement : l'application ne crée ni ne modifie jamais de créneau (ils sont créés par le script SQL).
Par défaut, une relation [@ManyToOne] est chargée immédiatement ([EAGER]) : lire un créneau lirait aussi son médecin, puis le compte de ce médecin... Toutes les relations de l'application sont au contraire déclarées [LAZY], et chaque requête demande explicitement les relations dont elle a besoin : on sait ainsi exactement quel SQL est envoyé.
11.8.6. entities/Rv.java
| // =========================================================================
// entities/Rv.java
// -------------------------------------------------------------------------
// Un rendez-vous (table [rv]) : un client, sur un créneau, un jour donné.
//
// Contrainte d'unicité SQL : unq1_rv (jour, id_creneau) - un créneau n'est
// réservé qu'une fois par jour. Créée par le script SQL, reconnue par son
// nom dans la couche métier.
// =========================================================================
package rdvmedecins.entities;
import java.time.LocalDate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
// LocalDate : une date SANS heure, qui correspond exactement au type SQL
// DATE de la colonne [jour]
@Column(name = "jour")
private LocalDate jour;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
@Column(name = "id_client", insertable = false, updatable = false)
private Integer idClient;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
@Column(name = "id_creneau", insertable = false, updatable = false)
private Integer idCreneau;
// un constructeur sans paramètre est exigé par JPA (Hibernate crée les
// objets lus en base avec lui)
protected Rv() {
}
// un nouveau rendez-vous
public Rv(LocalDate jour, Creneau creneau, Client client) {
this.jour = jour;
this.creneau = creneau;
this.client = client;
}
public LocalDate getJour() { return jour; }
public Client getClient() { return client; }
public Integer getIdClient() { return idClient; }
public Creneau getCreneau() { return creneau; }
public Integer getIdCreneau() { return idCreneau; }
}
|
- ligne 22 : la table des rendez-vous. Sa contrainte d'unicité [unq1_rv (jour, id_creneau)] garantit qu'un créneau n'est réservé qu'une fois par jour ;
- lignes 27-28 : le jour du rendez-vous. Le type [LocalDate] (paquetage [java.time]) représente une date sans heure : il correspond exactement au type SQL [DATE] de la colonne [jour], sans question de fuseau horaire. [Hibernate] fait lui-même la conversion dans les deux sens ;
- lignes 30-42 : le client et le créneau du rendez-vous : deux relations « plusieurs à un », écrites chacune avec un champ objet (qui écrit la colonne) et un champ numérique en lecture seule ([idClient], [idCreneau]) ;
- lignes 46-47 : [JPA] exige de toute entité un constructeur sans paramètre : c'est avec lui qu'[Hibernate] crée les objets qu'il lit en base, avant de remplir leurs champs. Dès qu'une classe déclare un constructeur (lignes 50-54), le compilateur ne lui fournit plus de constructeur par défaut : il faut donc l'écrire. Il est déclaré [protected] : [Hibernate] peut l'utiliser, mais le reste de l'application, qui ne le voit pas, est obligé de passer par le constructeur complet. [JPA] accepte un constructeur [public] ou [protected], pas [private] : [Hibernate] fabrique ses mandataires en créant des classes filles des entités, et une classe fille doit pouvoir appeler le constructeur de sa mère (c'est aussi pourquoi une classe entité ne doit pas être déclarée [final]) ;
- lignes 50-54 : le constructeur d'un nouveau rendez-vous : un jour, un créneau, un client. Il remplit les champs objets [creneau] et [client], qui écrivent les clés étrangères ; les champs [idClient] et [idCreneau], en lecture seule, ne seront remplis que lorsqu'un rendez-vous sera lu en base ;
- lignes 56-60 : les accesseurs, en lecture seulement : un rendez-vous n'est jamais modifié, seulement créé ou supprimé.
11.8.7. entities/Role.java
| // =========================================================================
// entities/Role.java
// -------------------------------------------------------------------------
// Les trois rôles d'un utilisateur (colonne users.role) :
// - ADMIN : l'administrateur - rendez-vous + gestion des médecins et clients ;
// - DOCTOR : un médecin - réserve et annule des rendez-vous ;
// - USER : un client (patient) - réserve pour lui-même, gère son compte.
//
// Une énumération (enum) : le compilateur refuse toute autre valeur. JPA la
// range en base sous forme de TEXTE ('ADMIN'...), comme le fait le script SQL
// (cf. @Enumerated dans User.java).
// =========================================================================
package rdvmedecins.entities;
public enum Role {
ADMIN,
DOCTOR,
USER
}
|
- lignes 15-19 : les trois rôles, sous la forme d'une énumération ([enum]) : une variable de type [Role] ne peut valoir que [Role.ADMIN], [Role.DOCTOR], [Role.USER] (ou [null]), et le compilateur refuse toute autre valeur. [Role] n'est pas une entité : c'est le type d'un champ de [User]. Chaque valeur a un nom ([name()] rend ["ADMIN"]) et un numéro d'ordre ([ordinal()] rend 0 pour [ADMIN]) : c'est le choix entre les deux, pour la base, que fait [@Enumerated] dans [User.java].
11.8.8. entities/User.java
| // =========================================================================
// entities/User.java
// -------------------------------------------------------------------------
// Un utilisateur autorisé à se connecter à l'application (table [users]).
// Chaque médecin et chaque client possède SON compte utilisateur (colonne
// [user] des tables [medecins] et [clients], cf. Personne.java).
//
// Contrainte d'unicité SQL : unq_users_login (un login n'appartient qu'à un
// seul compte). Elle est créée par le script database/dbrdvmedecins2.sql ;
// la couche métier la reconnaît par son NOM (cf. metier/MetierService.java).
// =========================================================================
package rdvmedecins.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
@Entity
@Table(name = "users")
public class User extends AbstractEntity {
@Column(name = "login", length = 30)
private String login;
// le mot de passe HACHÉ (bcrypt) - jamais en clair
@Column(name = "password", length = 100)
private String password;
// le nom affiché une fois connecté - pour un médecin ou un client :
// "prénom NOM" (ex : "Marie PELISSIER")
@Column(name = "nom", length = 40)
private String nom;
// ADMIN, DOCTOR ou USER, rangé en base sous forme de texte (STRING) et non
// de numéro d'ordre (ORDINAL : 0, 1, 2)
@Enumerated(EnumType.STRING)
@Column(name = "role", length = 10)
private Role role;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
}
|
- lignes 20-22 : les comptes des utilisateurs autorisés à se connecter : l'administrateur, chaque médecin et chaque client. La contrainte [unq_users_login] interdit deux comptes de même login ;
- lignes 24-25 : le login ;
- lignes 28-29 : le hachage [bcrypt] du mot de passe, jamais le mot de passe lui-même (cf. exemple 25) ;
- lignes 33-34 : le nom affiché dans la barre de navigation une fois connecté (« Marie PELISSIER ») ;
- lignes 38-40 : le rôle. Par défaut, [JPA] rangerait une énumération en base sous la forme de son numéro d'ordre ([EnumType.ORDINAL] : 0, 1, 2), ce qui aurait deux défauts : la colonne [role] du script SQL contient du texte, et ajouter un jour une valeur au milieu de l'énumération changerait le sens des numéros déjà enregistrés. [@Enumerated(EnumType.STRING)] range le nom de la valeur (['ADMIN']...) et le reconvertit en [Role] à la lecture ;
- lignes 42-52 : les accesseurs. La classe [User] ne déclare pas de relation vers le médecin ou le client qui la possède : la relation n'est connue que du côté de [Personne], qui porte la clé étrangère.
11.9. JPA, Hibernate et Spring Data
La version [ASP.NET Core] de l'application avait une classe [DbContext], pièce centrale d'[Entity Framework Core] : elle détenait la connexion, donnait accès aux tables, suivait les entités chargées et envoyait les modifications en base. La version [Spring] n'a pas d'équivalent écrit à la main : ces rôles sont répartis entre des objets que [Spring Boot] fabrique lui-même au démarrage, à partir de la configuration. Cette section explique qui fait quoi ; elle ne correspond à aucun fichier [Java] du projet.
11.9.1. Trois étages
L'accès aux données repose sur trois étages, que le starter [spring-boot-starter-data-jpa] apporte ensemble (cf. la section « La configuration du projet ») :
- [JPA] (Jakarta Persistence) est une norme : un ensemble d'interfaces et d'annotations ([@Entity], [EntityManager]...), sans code qui les fasse fonctionner ;
- [Hibernate] est une implémentation de cette norme : c'est lui qui lit les annotations des entités, écrit les ordres SQL, les envoie à la base par le pilote JDBC ([mysql-connector-j]), et transforme les lignes obtenues en objets [Java] ;
- [Spring Data JPA] ajoute par-dessus les repositories : il suffit de décrire un DAO par une interface pour que [Spring] en écrive l'implémentation (cf. « La couche DAO »).
11.9.2. L'EntityManager et le contexte de persistance
L'objet central de [JPA] est l'[EntityManager] (le « gestionnaire d'entités ») : c'est lui qui lit ([find]), enregistre ([persist]), supprime ([remove]) les entités et exécute les requêtes. Il gère un contexte de persistance : l'ensemble des entités qu'il a lues ou enregistrées, dites gérées (*managed*). Pour chaque entité gérée, il garde une copie de l'état lu en base. Au moment de la synchronisation avec la base (le *flush*), qui a lieu au plus tard juste avant le [COMMIT] de la transaction, il compare chaque entité à sa copie et envoie lui-même un [UPDATE] pour chacune de celles qui ont changé (*dirty checking*). Il n'y a donc pas de méthode « enregistrer les modifications » à appeler : modifier une entité gérée suffit. Un contexte de persistance garantit aussi qu'une même ligne n'y est représentée que par un seul objet : lire deux fois le médecin n° 1 dans la même transaction rend deux fois le même objet [Java].
C'est exactement le « suivi des modifications » du [DbContext] d'[Entity Framework Core], à une différence près, importante : avec [Spring], le contexte de persistance est lié à la transaction. Il est créé au début de la transaction, et fermé à sa fin ([COMMIT] ou [ROLLBACK]). Les entités qu'il gérait deviennent alors détachées : ce sont des objets [Java] ordinaires, que plus personne ne suit. Les modifier n'a plus aucun effet sur la base, et leurs relations [LAZY] qui n'ont pas été chargées ne peuvent plus l'être.
L'application ne manipule jamais directement l'[EntityManager] : les repositories de [Spring Data JPA] s'en servent pour elle. Lorsqu'une méthode d'un repository est appelée alors qu'une transaction est en cours (ouverte par la couche métier), elle utilise l'[EntityManager] de cette transaction : tous les repositories appelés pendant la transaction partagent donc le même contexte de persistance, la même connexion et la même transaction. Lorsqu'aucune transaction n'est en cours, les méthodes héritées de [JpaRepository] ([findById], [save]...) en ouvrent une, courte, le temps de l'appel (en lecture seule pour les lectures) ; les requêtes déclarées dans nos interfaces ([findAllByOrderByNomAscPrenomAsc], les [@Query]...) s'exécutent, elles, sans transaction : [Spring] crée un [EntityManager] pour la durée de l'appel, puis le ferme. Dans les deux cas, les entités rendues sont aussitôt détachées.
11.9.3. La configuration
Tout ce dont [Hibernate] a besoin est fabriqué par [Spring Boot] (c'est l'autoconfiguration) à partir des clés [spring.datasource.*] et [spring.jpa.*] du fichier [application.properties] (cf. la section « La configuration du projet ») :
| # ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql)
spring.datasource.url=jdbc:mysql://localhost:3306/dbrdvmedecins2
spring.datasource.username=root
spring.datasource.password=
# Hibernate ne crée ni ne modifie les tables : c'est le rôle du script SQL
spring.jpa.hibernate.ddl-auto=none
# la connexion à la base n'est pas gardée ouverte pendant la fabrication de la vue
spring.jpa.open-in-view=false
|
- lignes 2-4 : les clés [spring.datasource.*] décrivent la connexion : l'URL JDBC de la base (le protocole [jdbc:mysql:], la machine, le port, le nom de la base), l'utilisateur et le mot de passe. [Spring Boot] en fait une [DataSource], une source de connexions. Ouvrir une connexion à une base est coûteux : cette [DataSource] est un pool de connexions (la bibliothèque [HikariCP]), qui garde quelques connexions ouvertes et les prête à tour de rôle ;
- ligne 6 : [ddl-auto=none] : [Hibernate] ne crée ni ne modifie les tables (il pourrait les créer à partir des entités, avec la valeur [create], ou les compléter avec [update]) : la base est décrite par le script SQL, et elle seule ;
- ligne 8 : [open-in-view=false] ferme l'[EntityManager] dès la fin de la transaction, sans le garder ouvert jusqu'à la fin de la requête HTTP (cf. plus bas).
À partir de la [DataSource] et des entités, [Spring Boot] fabrique aussi l'[EntityManagerFactory] (la fabrique des [EntityManager]), un gestionnaire de transactions ([JpaTransactionManager]) et un [TransactionTemplate], que la couche métier utilise pour ouvrir ses transactions. Le dialecte SQL (les particularités de [MySQL] : [limit], accents graves...) est déduit automatiquement de la base à laquelle on se connecte.
Le fichier [application-dev.properties], lu en plus quand le profil [dev] est actif (c'est le cas avec [./mvnw spring-boot:run]), ajoute la clé [spring.jpa.show-sql=true] : [Hibernate] affiche alors dans la console chaque ordre SQL qu'il envoie. Toutes les requêtes montrées dans ce chapitre ont été relevées ainsi.
Comparé au [DbContext] de la version [ASP.NET Core], il manque donc deux choses, qui sont devenues inutiles : la liste des tables (les entités sont trouvées automatiquement, parce qu'elles sont dans le paquetage de l'application ou un de ses sous-paquetages) et les précisions de [OnModelCreating] (la relation « un pour un » est dite par [@OneToOne], la conversion du rôle en texte par [@Enumerated], l'augmentation de la version par [@Version]).
11.9.4. open-in-view=false : charger explicitement les relations
Par défaut ([spring.jpa.open-in-view=true]), [Spring Boot] ouvre un [EntityManager] au début de chaque requête HTTP et ne le ferme qu'après la fabrication de la vue : une vue peut alors lire une relation [LAZY] non chargée ([${rv.client.nom}]), et [Hibernate] envoie la requête nécessaire au moment de l'affichage. C'est commode, mais les requêtes SQL partent alors de n'importe où, y compris d'une vue [Thymeleaf], la connexion reste prise pendant toute la fabrication de la page, et une boucle sur 20 rendez-vous peut déclencher 20 petites requêtes au lieu d'une (le problème dit « N+1 »). [Spring Boot] affiche d'ailleurs un avertissement au démarrage lorsque la clé n'est pas précisée.
L'application choisit [open-in-view=false] : l'[EntityManager] est fermé à la fin de chaque transaction (ou de chaque appel d'un repository fait hors transaction), et les entités qui sortent de la couche métier sont détachées. La conséquence est une règle à respecter partout : chaque lecture charge explicitement les relations dont la page aura besoin, par [@EntityGraph] ou [join fetch] (cf. « La couche DAO »). Lire une relation [LAZY] qui n'a pas été chargée, par exemple le compte d'un médecin lu par une requête qui ne l'a pas demandé, provoquerait une [LazyInitializationException] : le mandataire ne peut plus rien lire, l'[EntityManager] est fermé. En contrepartie, on sait exactement quelles requêtes sont envoyées, et à quel moment : toutes dans la couche DAO.
11.10. Le domaine : l'agenda
Le dossier [domain] contient deux classes qui ne correspondent à aucune table : elles décrivent l'agenda d'un médecin pour un jour, calculé par la couche métier ([MetierService.getAgendaMedecinJour]) et affiché par le contrôleur de l'agenda.
11.10.1. domain/CreneauMedecinJour.java
| // =========================================================================
// domain/CreneauMedecinJour.java
// -------------------------------------------------------------------------
// Un objet "métier" (sans table) qui associe un créneau horaire au
// rendez-vous éventuellement pris sur ce créneau, un jour donné.
//
// Un record Java est une classe dont les champs sont fixés une fois pour
// toutes par le constructeur (objet immuable) : exactement ce qu'il faut
// pour un objet calculé, que personne ne modifie ensuite.
// =========================================================================
package rdvmedecins.domain;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
// rv : null si le créneau est libre ce jour-là
public record CreneauMedecinJour(Creneau creneau, Rv rv) {
}
|
- ligne 17 : un créneau, et le rendez-vous pris sur ce créneau ce jour-là, ou [null] si le créneau est libre. Un record (depuis [Java] 16) déclare en une ligne une classe dont les champs sont fixés une fois pour toutes par le constructeur : le compilateur écrit lui-même les champs privés et [final] (non modifiables), le constructeur à deux paramètres, les accesseurs [creneau()] et [rv()] (sans le préfixe [get]), et les méthodes [equals], [hashCode] et [toString]. Le corps du record, entre accolades, est ici vide. Nous avons déjà rencontré les records pour les données transmises à la couche métier (cf. exemple 28).
11.10.2. domain/AgendaMedecinJour.java
| // =========================================================================
// domain/AgendaMedecinJour.java
// -------------------------------------------------------------------------
// L'agenda complet d'un médecin pour un jour donné : la liste de ses
// créneaux horaires, chacun libre ou occupé par un rendez-vous. Calculé par
// MetierService.getAgendaMedecinJour, transformé en tableau HTML par
// AgendaController.
// =========================================================================
package rdvmedecins.domain;
import java.time.LocalDate;
import java.util.List;
import rdvmedecins.entities.Medecin;
public record AgendaMedecinJour(Medecin medecin, LocalDate jour, List<CreneauMedecinJour> creneauxMedecinJour) {
}
|
L'agenda (ligne 16) : un médecin, un jour, et la liste de ses créneaux, libres ou occupés, dans l'ordre chronologique. Les vues [Thymeleaf] savent lire les accesseurs d'un record : [${agenda.jour}] appelle [jour()].
11.11. La couche DAO
Un DAO par table, comme à l'exemple 28 : des méthodes sans règle de gestion, qui ne font qu'interroger ou modifier la base. On les appelle ici des repositories (« dépôts »). Avec [Spring Data JPA], un repository est une simple interface : au démarrage de l'application, [Spring] écrit lui-même une classe qui l'implémente, en fait un bean, et l'injecte là où on le demande. L'interface hérite de [JpaRepository<T, ID>] ([T] : le type de l'entité, [ID] : le type de sa clé primaire), qui apporte les méthodes courantes : [findById], [findAll], [save], [deleteById], [getReferenceById]... On y ajoute les requêtes propres à l'application, de trois façons :
- par le nom de la méthode : [Spring Data JPA] analyse le nom ([find] ... [By] *critères* [OrderBy] *tri*) et en déduit la requête. Une faute dans un nom de champ est détectée au démarrage, qui échoue ;
- en JPQL, avec l'annotation [@Query]. [JPQL] (Jakarta Persistence Query Language) est le langage de requête de [JPA] : il ressemble à SQL, mais porte sur les entités et leurs champs ([Rv], [rv.client]), et non sur les tables et leurs colonnes. [Hibernate] le traduit en SQL ;
- en SQL, avec [@Query(nativeQuery = true)], quand [JPQL] ne suffit pas.
Les méthodes qui peuvent ne rien trouver rendent un [Optional<T>] (paquetage [java.util]) : une boîte qui contient un objet ou qui est vide. L'appelant ne peut pas utiliser le résultat sans se demander s'il est présent ([isPresent()], [orElseThrow(...)]...) : il n'y a pas de [null] oublié.
Comme les entités, les repositories ne gèrent pas les transactions : c'est la couche métier qui les ouvre.
11.11.1. repositories/PersonneRepository.java
Les repositories des médecins et des clients seraient identiques : une interface générique les écrit une seule fois.
| // =========================================================================
// repositories/PersonneRepository.java
// -------------------------------------------------------------------------
// La couche DAO commune aux médecins et aux clients.
//
// Avec Spring Data JPA, un DAO est une simple INTERFACE : Spring en écrit
// lui-même l'implémentation au démarrage. JpaRepository<T, Integer> apporte
// déjà findById, findAll, save, delete... ; on y ajoute nos requêtes :
// - soit par le NOM de la méthode, que Spring Data traduit en requête
// (findFirstByPrenomAndNom -> ... WHERE prenom = ? AND nom = ? LIMIT 1) ;
// - soit par une requête JPQL écrite dans une annotation @Query. JPQL
// ressemble à SQL, mais porte sur les classes et leurs champs, pas sur
// les tables et leurs colonnes.
//
// Les DAO des médecins et des clients seraient identiques : cette interface
// GÉNÉRIQUE les écrit une seule fois, T valant Medecin ou Client.
// @NoRepositoryBean : Spring ne crée pas de DAO pour cette interface
// elle-même, seulement pour ses sous-interfaces MedecinRepository et
// ClientRepository. Dans les requêtes, #{#entityName} désigne l'entité de
// la sous-interface (Medecin ou Client).
//
// Transactions : toutes les méthodes des DAO d'une même requête HTTP
// utilisent la transaction en cours, s'il y en a une (cf. MetierService).
// =========================================================================
package rdvmedecins.repositories;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.NoRepositoryBean;
import rdvmedecins.entities.Personne;
@NoRepositoryBean
public interface PersonneRepository<T extends Personne> extends JpaRepository<T, Integer> {
// liste triée par nom puis prénom, compte utilisateur compris.
// @EntityGraph(attributePaths = "user") : ramène AUSSI le compte, par une
// jointure (SELECT ... FROM medecins LEFT JOIN users ...). Sans elle, le
// compte ne serait lu qu'au premier accès - impossible en dehors d'une
// transaction : ces listes sont gardées en mémoire par le cache
// (web/ApplicationModel.java), bien après la fin de la requête.
@EntityGraph(attributePaths = "user")
List<T> findAllByOrderByNomAscPrenomAsc();
// une personne et son compte
@EntityGraph(attributePaths = "user")
Optional<T> findWithUserById(int id);
// la personne dont le compte utilisateur est [idUser]
@EntityGraph(attributePaths = "user")
Optional<T> findByIdUser(int idUser);
// la personne qui porte ces prénom et nom, s'il y en a une. La comparaison
// est faite par MySQL, donc avec la collation de la base
// (utf8mb4_unicode_ci : sans tenir compte des majuscules ni des accents) -
// exactement la même règle que celle de la contrainte d'unicité SQL.
Optional<T> findFirstByPrenomAndNom(String prenom, String nom);
// un DELETE envoyé directement à la base, sans lire la personne d'abord.
// @Modifying : la requête modifie des données (ce n'est pas un SELECT)
@Modifying
@Query("delete from #{#entityName} p where p.id = :id")
void supprimer(int id);
}
|
Commentons ce code :
- ligne 39 : [PersonneRepository<T extends Personne>] est une interface générique : [T] est un paramètre de type, remplacé par un type réel ([Medecin] ou [Client]) dans les sous-interfaces, comme [T] dans [List<T>]. [extends Personne] n'accepte pour [T] que [Personne] ou ses classes filles : dans les requêtes, [T] a donc forcément les champs [nom], [prenom], [user], [idUser]... L'interface hérite de [JpaRepository<T, Integer>] : entité [T], clé primaire de type [Integer] ;
- ligne 38 : [@NoRepositoryBean] demande à [Spring Data JPA] de ne pas fabriquer de repository pour cette interface elle-même, qui ne désigne aucune entité précise ([T] n'est pas connu), mais seulement pour ses sous-interfaces [MedecinRepository] et [ClientRepository] ;
- lignes 47-48 : toutes les personnes, triées par nom puis prénom. Le nom de la méthode est la requête : [findAll] (le mot [All] n'est là que pour la lisibilité), [By] sans critère (toutes les lignes), [OrderByNomAscPrenomAsc] (tri croissant par nom, puis par prénom). [@EntityGraph(attributePaths = "user")] demande de charger aussi la relation [user] dans la même requête, par une jointure ; sans elle, le champ [user] de chaque personne serait un mandataire non chargé. C'est indispensable ici : ces listes sont gardées en mémoire par le cache ([web/ApplicationModel.java]) bien après la fin de la transaction, et les vues affichent le login de chaque personne ;
- lignes 51-52 : une personne et son compte, ou un [Optional] vide. [JpaRepository] fournit déjà [findById], mais sans le compte ; on ne peut pas lui ajouter [@EntityGraph] sans changer toutes ses utilisations. On déclare donc une autre méthode : dans un nom de méthode, [Spring Data JPA] ignore le texte placé entre [find] et [By] (ici [WithUser]), qui ne sert qu'à dire au lecteur ce que la méthode ramène. La requête est celle de [findById] ([where id = ?]), et [@EntityGraph] y ajoute la jointure ;
- lignes 55-56 : la personne dont le compte est [idUser] : le critère porte sur le champ [idUser] (en lecture seule), donc directement sur la colonne [user]. C'est ainsi qu'un patient connecté, dont le jeton contient l'identifiant du compte, retrouve sa fiche de client ;
- ligne 62 : un homonyme éventuel. [First] limite le résultat à une ligne ([limit ?] dans le SQL), [ByPrenomAndNom] donne les deux critères, reliés par [and]. Les paramètres de la méthode sont associés aux critères dans l'ordre. La comparaison est faite par [MySQL], selon la collation de la base, sans tenir compte des majuscules ni des accents, exactement comme la contrainte d'unicité ;
- lignes 66-68 : la suppression d'une personne par une requête [JPQL] [delete], envoyée directement à la base, sans lire la personne d'abord (l'équivalent de [ExecuteDeleteAsync] d'[Entity Framework Core]). [@Modifying] signale que la requête modifie des données : sans elle, [Spring Data JPA] la traiterait comme un [select]. Une telle requête doit s'exécuter dans une transaction qui accepte les écritures : c'est la couche métier qui l'ouvre. Elle ne passe pas par le contexte de persistance, ni donc par le verrou optimiste ;
- ligne 67 : l'interface est générique, mais une requête [JPQL] doit nommer une entité précise : [delete from Medecin] ou [delete from Client]. [#{...}] introduit une expression SpEL (Spring Expression Language, le langage d'expressions de [Spring]), évaluée au démarrage ; la variable [#entityName] y désigne le nom de l'entité du repository concret. Pour [MedecinRepository], la requête devient [delete from Medecin p where p.id = :id] ;
- lignes 67-68 : [:id] est un paramètre nommé de la requête, associé au paramètre [id] de la méthode (par son nom). Sa valeur est transmise à la base séparément du texte de la requête : pas de concaténation de chaînes, donc pas d'injection SQL possible.
Voici les requêtes réellement envoyées par [Hibernate] pour la liste des clients (ligne 48) et pour la recherche d'un homonyme parmi les médecins (ligne 62) :
| Hibernate: select c1_0.id,c1_0.`user`,c1_0.nom,c1_0.prenom,c1_0.titre,u1_0.id,u1_0.login,u1_0.nom,u1_0.password,u1_0.role,u1_0.version,c1_0.version from clients c1_0 left join users u1_0 on u1_0.id=c1_0.`user` order by c1_0.nom,c1_0.prenom
Hibernate: select m1_0.id,m1_0.`user`,m1_0.nom,m1_0.prenom,m1_0.titre,m1_0.version from medecins m1_0 where m1_0.prenom=? and m1_0.nom=? limit ?
|
La première lit les clients et leurs comptes en une seule requête ([left join users]) ; la seconde, sans [@EntityGraph], ne lit que la table [medecins].
11.11.2. repositories/MedecinRepository.java
| // =========================================================================
// repositories/MedecinRepository.java
// -------------------------------------------------------------------------
// Le DAO des médecins : tout est hérité de PersonneRepository.
// Spring Data en fabrique l'implémentation au démarrage.
// =========================================================================
package rdvmedecins.repositories;
import rdvmedecins.entities.Medecin;
public interface MedecinRepository extends PersonneRepository<Medecin> {
}
|
Le repository des médecins (ligne 11) : [PersonneRepository<Medecin>], sans rien de plus. C'est cette interface, sans [@NoRepositoryBean], que [Spring Data JPA] implémente : toutes les méthodes héritées y portent sur l'entité [Medecin].
11.11.3. repositories/ClientRepository.java
| // =========================================================================
// repositories/ClientRepository.java
// -------------------------------------------------------------------------
// Le DAO des clients : celui des personnes, plus une méthode de verrouillage.
// =========================================================================
package rdvmedecins.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import rdvmedecins.entities.Client;
public interface ClientRepository extends PersonneRepository<Client> {
// -----------------------------------------------------------------------
// pose un VERROU sur la ligne d'un client jusqu'à la fin de la
// transaction en cours (SELECT ... FOR UPDATE), et renvoie son id
// (Optional vide si le client n'existe pas). Une seconde transaction qui
// demande le même verrou ATTEND que la première soit terminée.
// Utilisé pour la prise de rendez-vous (cf. MetierService.ajouterRv) :
// deux réservations simultanées pour le même patient sont ainsi traitées
// l'une APRÈS l'autre, et la seconde voit le rendez-vous créé par la première.
//
// nativeQuery = true : la requête est du SQL, envoyé tel quel à la base,
// et non du JPQL traduit par Hibernate. JPA sait aussi poser ce verrou
// (@Lock(LockModeType.PESSIMISTIC_WRITE) sur une requête JPQL), mais
// Hibernate écrit alors "FOR UPDATE OF c1_0", une syntaxe de MySQL 8 que
// MariaDB (le "MySQL" de certaines distributions) refuse. Ce SQL-là
// fonctionne avec les deux.
// :id est un PARAMÈTRE de la requête (pas de concaténation de chaînes,
// donc pas d'injection SQL possible).
// -----------------------------------------------------------------------
@Query(value = "select id from clients where id = :id for update", nativeQuery = true)
Optional<Integer> verrouiller(int id);
}
|
- ligne 14 : le repository des clients : celui des personnes, avec [T = Client], plus une méthode ;
- lignes 34-35 : la lecture d'un client avec verrou. [SELECT ... FOR UPDATE] pose un verrou sur la ligne jusqu'à la fin de la transaction en cours : une autre transaction qui demande le même verrou attend que la première soit terminée. La prise de rendez-vous ([MetierService.ajouterRv]) s'en sert pour traiter l'une après l'autre deux réservations simultanées du même patient : la seconde voit le rendez-vous créé par la première, et respecte ainsi la règle « un seul rendez-vous à venir ». La méthode rend seulement l'identifiant du client ([select id]), dans un [Optional] vide si le client n'existe pas : elle ne sert qu'à poser le verrou et à vérifier l'existence du client ;
- ligne 34 : [nativeQuery = true] : la requête est du SQL, envoyé tel quel à la base, et non du [JPQL]. [JPA] sait pourtant poser ce verrou lui-même : l'annotation [@Lock(LockModeType.PESSIMISTIC_WRITE)], ajoutée à une requête [JPQL], demande à [Hibernate] d'y ajouter la clause de verrouillage. Mais pour [MySQL] 8, [Hibernate] écrit alors [for update of c1_0] (le verrou ne porte que sur la table désignée), une syntaxe propre à [MySQL] 8 que [MariaDB], souvent installé à la place de [MySQL], refuse. Le SQL écrit à la main, [for update] sans [of], fonctionne avec les deux. [:id] est, là encore, un paramètre de la requête.
La requête, telle qu'elle apparaît dans la console lors d'une réservation :
| Hibernate: select id from clients where id = ? for update
|
11.11.4. repositories/CreneauRepository.java
| // =========================================================================
// repositories/CreneauRepository.java
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Creneau].
// =========================================================================
package rdvmedecins.repositories;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import rdvmedecins.entities.Creneau;
public interface CreneauRepository extends JpaRepository<Creneau, Integer> {
// un créneau et son médecin
@EntityGraph(attributePaths = "medecin")
Optional<Creneau> findWithMedecinById(int id);
// les créneaux d'un médecin, dans l'ordre chronologique
List<Creneau> findByIdMedecinOrderByHdebutAscMdebutAsc(int idMedecin);
// supprime tous les créneaux d'un médecin (suppression d'un médecin) :
// DELETE FROM creneaux WHERE id_medecin = ?
@Modifying
@Query("delete from Creneau c where c.idMedecin = :idMedecin")
void supprimerParMedecin(int idMedecin);
}
|
- ligne 18 : un repository ordinaire : entité [Creneau], clé primaire [Integer] ;
- lignes 21-22 : un créneau et son médecin ([@EntityGraph(attributePaths = "medecin")]), sur le modèle de [findWithUserById] ;
- ligne 25 : les créneaux d'un médecin, dans l'ordre chronologique : [ByIdMedecin] filtre sur le champ [idMedecin] (donc la colonne [id_medecin]), [OrderByHdebutAscMdebutAsc] trie par heure puis par minute de début. [Hibernate] envoie [select ... from creneaux c1_0 where c1_0.id_medecin=? order by c1_0.hdebut,c1_0.mdebut] ;
- lignes 29-31 : la suppression des créneaux d'un médecin, par un seul [DELETE]. La couche métier l'appelle avant de supprimer le médecin : sans cela, la clé étrangère [creneaux.id_medecin] interdirait la suppression. La méthode ne s'appelle pas [deleteByIdMedecin] : un nom de ce genre serait lui aussi compris par [Spring Data JPA], mais la requête déduite lirait d'abord les créneaux pour les supprimer ensuite un par un ; la requête [@Query] envoie un seul ordre [DELETE].
11.11.5. repositories/RvRepository.java
| // =========================================================================
// repositories/RvRepository.java
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Rv] (un rendez-vous).
//
// Les requêtes JPQL naviguent d'une entité à l'autre par les champs de
// relation (rv.creneau.idMedecin) : Hibernate écrit lui-même les jointures
// SQL nécessaires. "join fetch" ramène en plus l'entité jointe (l'équivalent
// d'un @EntityGraph).
// =========================================================================
package rdvmedecins.repositories;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import rdvmedecins.entities.Rv;
public interface RvRepository extends JpaRepository<Rv, Integer> {
// un rendez-vous, son client et son créneau
@Query("select rv from Rv rv join fetch rv.client join fetch rv.creneau where rv.id = :id")
Optional<Rv> lire(int id);
// les rendez-vous d'un médecin un jour donné, client et créneau compris.
// La table [rv] ne référence pas directement le médecin : on passe par le créneau.
@Query("""
select rv from Rv rv join fetch rv.client join fetch rv.creneau c
where c.idMedecin = :idMedecin and rv.jour = :jour""")
List<Rv> rvMedecinJour(int idMedecin, LocalDate jour);
// les rendez-vous d'un client à partir du jour [depuis] (inclus), dans
// l'ordre chronologique, avec le créneau ET le médecin de ce créneau
@Query("""
select rv from Rv rv join fetch rv.creneau c join fetch c.medecin
where rv.idClient = :idClient and rv.jour >= :depuis
order by rv.jour, c.hdebut, c.mdebut""")
List<Rv> rvAVenirClient(int idClient, LocalDate depuis);
// nombre de rendez-vous d'un client à partir du jour [depuis] (inclus)
long countByIdClientAndJourGreaterThanEqual(int idClient, LocalDate depuis);
// nombre de rendez-vous (passés ou futurs) d'un médecin
@Query("select count(rv) from Rv rv where rv.creneau.idMedecin = :idMedecin")
long compterParMedecin(int idMedecin);
// supprime un rendez-vous : DELETE FROM rv WHERE id = ?
@Modifying
@Query("delete from Rv rv where rv.id = :id")
void supprimer(int id);
// supprime TOUS les rendez-vous (passés et futurs) d'un client
@Modifying
@Query("delete from Rv rv where rv.idClient = :idClient")
void supprimerParClient(int idClient);
}
|
Commentons ce code :
- lignes 26-27 : un rendez-vous avec son client et son créneau. En [JPQL], [join fetch rv.client] fait une jointure et charge l'entité jointe dans le résultat : c'est l'équivalent, écrit dans la requête, de [@EntityGraph]. Une jointure se fait en suivant un champ de relation ([rv.client]) : [Hibernate] connaît la clé étrangère ([@JoinColumn]) et écrit lui-même la condition [on]. Avec [@Query], le nom de la méthode est libre : c'est la requête écrite qui est exécutée. Le nom [findById] est de toute façon déjà pris par la méthode héritée de [JpaRepository], qui ne charge ni le client ni le créneau ;
- lignes 31-34 : les rendez-vous d'un médecin pour un jour, chacun avec son client et son créneau. La requête est écrite dans un bloc de texte [Java] (depuis [Java] 15) : une chaîne délimitée par trois guillemets ["""], qui peut s'étendre sur plusieurs lignes. La table [rv] ne référence pas directement les médecins : on passe par le créneau, auquel la jointure donne l'alias [c], et on filtre sur [c.idMedecin]. [rv.jour = :jour] compare deux dates : [Hibernate] transmet le [LocalDate] comme une valeur SQL [DATE] ;
- lignes 38-42 : les rendez-vous d'un client à partir du jour [depuis], avec leur créneau et le médecin de ce créneau : [join fetch c.medecin] suit la relation du créneau vers son médecin (deux jointures en cascade). Le tri porte sur le jour, puis sur l'heure du créneau. C'est ce qu'affiche « Mon espace ». Le médecin chargé n'a pas son compte ([user]) : la page n'en a pas besoin ;
- ligne 45 : le nombre de rendez-vous d'un client à partir du jour [depuis], pour la règle « un seul rendez-vous à venir » : [count] produit un [select count(...)], [GreaterThanEqual] la comparaison [>=]. Le résultat est de type [long] ;
- lignes 48-49 : le nombre de rendez-vous d'un médecin, pour refuser la suppression d'un médecin qui en a. [rv.creneau.idMedecin] suit la relation [creneau] sans [join fetch] : [Hibernate] ajoute la jointure avec [creneaux] pour filtrer, sans rien charger ;
- lignes 52-54 : l'annulation d'un rendez-vous, par un [DELETE] direct. [JpaRepository] offre bien [deleteById], mais cette méthode lit d'abord le rendez-vous, puis le supprime ;
- lignes 57-59 : la suppression de tous les rendez-vous d'un client, passés et futurs : la couche métier l'appelle avant de supprimer le client, que la clé étrangère [rv.id_client] protégerait sinon.
Voici la requête de la ligne 42, telle que [Hibernate] la traduit en SQL : les deux [join fetch] sont devenus deux jointures, et les colonnes du créneau et du médecin sont lues en même temps que celles du rendez-vous :
| Hibernate: select r1_0.id,r1_0.id_client,c1_0.id,c1_0.hdebut,c1_0.hfin,c1_0.id_medecin,c1_0.mdebut,m1_0.id,m1_0.`user`,m1_0.nom,m1_0.prenom,m1_0.titre,m1_0.version,c1_0.mfin,c1_0.version,r1_0.id_creneau,r1_0.jour,r1_0.version from rv r1_0 join creneaux c1_0 on c1_0.id=r1_0.id_creneau join medecins m1_0 on m1_0.id=c1_0.id_medecin where r1_0.id_client=? and r1_0.jour>=? order by r1_0.jour,c1_0.hdebut,c1_0.mdebut
|
On remarque qu'un [join fetch] produit une jointure interne ([join]), alors que l'[@EntityGraph] de [PersonneRepository] produisait une jointure externe ([left join]) : ici, tout rendez-vous a forcément un créneau, et tout créneau un médecin.
11.11.6. repositories/UserRepository.java
| // =========================================================================
// repositories/UserRepository.java
// -------------------------------------------------------------------------
// Couche DAO de l'entité [User]. Créer (ou supprimer) un médecin ou un
// client crée (ou supprime) aussi son compte - cf. metier/MetierService.java.
// =========================================================================
package rdvmedecins.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import rdvmedecins.entities.User;
public interface UserRepository extends JpaRepository<User, Integer> {
// Optional.empty() si aucun utilisateur ne porte ce login
Optional<User> findByLogin(String login);
@Modifying
@Query("delete from User u where u.id = :id")
void supprimer(int id);
}
|
- ligne 20 : le compte qui porte ce login, ou un [Optional] vide. Il sert à la connexion ([AuthService]) et à vérifier qu'un login est libre avant de l'attribuer ;
- lignes 22-24 : la suppression d'un compte, par un [DELETE] direct. L'ordre est inverse de celui de la création : la couche métier supprime d'abord la personne, qui référence le compte, puis le compte.
La création et la modification d'un compte utilisent la méthode [save] héritée de [JpaRepository] : pour une entité nouvelle (identifiant [null]), elle appelle [persist] de l'[EntityManager] (un [INSERT]) ; pour une entité déjà enregistrée, elle appelle [merge]. Nous verrons que la couche métier n'a en fait besoin de [save] que pour les créations.
11.12. La couche métier
La couche métier rassemble les règles de gestion de l'application : pas de rendez-vous dans le passé, un seul rendez-vous à venir par patient, unicité des logins et des personnes, suppression d'un médecin ou d'un client. Elle s'appuie sur la couche DAO (dossier [repositories]) pour lire et écrire, ouvre les transactions, et ne sait rien du HTML ni de la langue de l'interface. C'est, en plus complet, la couche [PersonnesMetier] de l'exemple 28.
11.12.1. metier/Dates.java
| // =========================================================================
// metier/Dates.java
// -------------------------------------------------------------------------
// La date du jour. Elle sert à des RÈGLES DE GESTION (pas de réservation
// dans le passé, un seul rendez-vous À VENIR par patient) : elle vit donc
// dans la couche métier. La couche web la réutilise.
// =========================================================================
package rdvmedecins.metier;
import java.time.LocalDate;
public final class Dates {
private Dates() {
}
// la date du jour, dans le fuseau horaire du serveur
public static LocalDate aujourdhui() {
return LocalDate.now();
}
}
|
- ligne 12 : une classe utilitaire, qui ne fait que regrouper une méthode [static], appelée par le nom de la classe ([Dates.aujourdhui()]). [final] interdit d'en hériter ;
- lignes 14-15 : un constructeur [private] interdit de créer des objets [Dates], qui n'auraient aucun sens ;
- lignes 18-20 : la date du jour, dans le fuseau horaire du serveur, sous la forme d'un [LocalDate] (une date sans heure, le type de [Rv.jour]). Deux [LocalDate] se comparent par des méthodes : [jour.isBefore(aujourdhui)], [isAfter], [equals]. La date du jour sert à des règles de gestion, elle vit donc dans la couche métier ; la couche web la réutilise (par exemple dans [AgendaController], pour savoir si un jour est encore réservable).
11.12.2. metier/MetierException.java
| // =========================================================================
// metier/MetierException.java
// -------------------------------------------------------------------------
// L'exception levée par la couche métier quand une RÈGLE DE GESTION est
// violée (login déjà pris, médecin qui a encore des rendez-vous, créneau
// déjà réservé...). Elle ne transporte pas un texte, mais une CLÉ de
// traduction (ex : "ERRORS.LOGIN_TAKEN") : c'est la couche web qui la
// traduira en français ou en anglais au moment d'afficher la page
// (cf. i18n/fr.json, i18n/en.json).
//
// Une MetierException n'est pas une anomalie : c'est une réponse normale
// ("ce login est déjà pris"), affichée à l'utilisateur. Toute autre
// exception est une vraie anomalie, qui aboutit à la page d'erreur 500.
//
// RuntimeException : une exception "non vérifiée" ; les méthodes qui la
// lèvent n'ont pas à la déclarer (throws), et Spring annule (ROLLBACK) la
// transaction qu'elle interrompt.
// =========================================================================
package rdvmedecins.metier;
import java.util.Map;
public class MetierException extends RuntimeException {
// clé de traduction du message
private final String cle;
// paramètres éventuels du message ({minutes}...)
private final Map<String, String> parametres;
public MetierException(String cle) {
this(cle, Map.of());
}
public MetierException(String cle, Map<String, String> parametres) {
super(cle);
this.cle = cle;
this.parametres = parametres;
}
public String getCle() {
return cle;
}
public Map<String, String> getParametres() {
return parametres;
}
}
|
- ligne 23 : l'exception levée quand une règle de gestion refuse une opération (cf. exemple 28). Elle hérite de [RuntimeException] : c'est une exception non vérifiée. En [Java], une méthode qui peut lever une exception vérifiée doit le déclarer ([throws ...]), et l'appelant doit la traiter ; une exception non vérifiée échappe à cette obligation. Autre conséquence, importante : [Spring] annule ([ROLLBACK]) une transaction interrompue par une exception non vérifiée (avec [@Transactional], une exception vérifiée laisserait au contraire, par défaut, la transaction se valider) ;
- lignes 26 et 29 : la clé de traduction du message ([ERRORS.LOGIN_TAKEN]) et ses paramètres éventuels. Différence avec l'exemple 28 : l'exception ne transporte pas un texte en français, mais une clé. La couche métier ignore ainsi la langue de l'interface : c'est la couche web qui traduira la clé avec les dictionnaires [i18n/fr.json] et [i18n/en.json] (cf. la section « Les dictionnaires »). Les champs sont [final] : fixés par le constructeur, ils ne changent plus ;
- lignes 31-33 : le constructeur sans paramètres de message. [this(cle, Map.of())] appelle l'autre constructeur de la classe, avec un dictionnaire vide : [Map.of()] fabrique un dictionnaire non modifiable. La couche web n'a donc jamais à tester [null]. Les exceptions levées par [MetierService] n'utilisent en fait que ce constructeur ;
- ligne 36 : [super(cle)] transmet la clé au constructeur de la classe mère, qui en fait le message de l'exception ([getMessage()]), utile dans les traces d'erreur ;
- lignes 41-47 : les accesseurs.
Une [MetierException] n'est pas une anomalie : c'est une réponse normale (« ce login est déjà pris »), que les contrôleurs interceptent pour l'afficher à l'utilisateur, dans la page ou dans un message flash. Toute autre exception est une vraie anomalie, qui aboutit à la page d'erreur 500.
11.12.3. metier/Metier.java
| // =========================================================================
// metier/Metier.java
// -------------------------------------------------------------------------
// Le contrat de la couche métier : lectures, agenda, rendez-vous, gestion
// des médecins et des clients.
//
// Optional<T> : un résultat qui peut être absent (un médecin qui n'existe
// pas...). L'appelant est obligé de traiter ce cas : pas de null oublié.
// =========================================================================
package rdvmedecins.metier;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
public interface Metier {
// ------------------------------------------------ lectures
List<Client> getAllClients();
List<Medecin> getAllMedecins();
List<Creneau> getAllCreneaux(int idMedecin);
List<Rv> getRvMedecinJour(int idMedecin, LocalDate jour);
Optional<Client> getClientById(int id);
Optional<Medecin> getMedecinById(int id);
Optional<Rv> getRvById(int id);
Optional<Creneau> getCreneauById(int id);
Optional<AgendaMedecinJour> getAgendaMedecinJour(int idMedecin, LocalDate jour);
// le client (patient) dont le compte utilisateur est [idUser]
Optional<Client> getClientByUserId(int idUser);
// les rendez-vous d'un client, d'aujourd'hui ou futurs, avec créneau et médecin
List<Rv> getRvAVenirClient(int idClient);
// ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
// lève une MetierException si :
// - le jour est passé ("ERRORS.PAST_DATE") ;
// - le patient a déjà un rendez-vous à venir ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT") ;
// - le créneau est déjà pris ce jour-là ("ERRORS.SLOT_ALREADY_BOOKED").
Rv ajouterRv(LocalDate jour, Creneau creneau, Client client);
void supprimerRv(Rv rv);
// ------------------------------------------------ médecins (ADMIN)
// crée le médecin ET son compte utilisateur (rôle DOCTOR)
Medecin ajouterMedecin(DonneesPersonne donnees);
// modifie le médecin ET son compte utilisateur
Medecin modifierMedecin(int id, DonneesPersonne donnees);
// supprime le médecin, ses créneaux ET son compte - refusé s'il a des rendez-vous
void supprimerMedecin(int id);
// ------------------------------------------------ clients
// (l'ADMIN ; ajouterClient sert aussi à l'inscription d'un patient,
// modifierClient et supprimerClient au patient pour son propre compte)
// crée le client ET son compte utilisateur (rôle USER)
Client ajouterClient(DonneesPersonne donnees);
Client modifierClient(int id, DonneesPersonne donnees);
// supprime le client, son compte ET tous ses rendez-vous (passés et futurs)
void supprimerClient(int id);
}
|
- ligne 22 : le contrat de la couche métier : ce que les autres couches peuvent lui demander, sans dire comment c'est fait (les interfaces ont été vues à l'exemple 06) ;
- lignes 25-47 : les lectures. Celles qui peuvent ne rien trouver (lignes 33-44) rendent un [Optional] : un médecin inconnu donne un [Optional] vide, jamais [null]. Les listes, elles, sont vides quand il n'y a rien à rendre ;
- lignes 50-56 : les rendez-vous. Le commentaire de [ajouterRv] énumère les clés d'erreur possibles : c'est une partie du contrat, que les contrôleurs doivent connaître ;
- lignes 60-66 : la gestion des médecins (réservée à l'ADMIN). Chaque médecin est créé, modifié et supprimé avec son compte utilisateur ;
- lignes 72-77 : la gestion des clients, avec la même logique. [ajouterClient] sert aussi à l'inscription d'un patient, [modifierClient] et [supprimerClient] au patient qui gère son propre compte.
Les méthodes d'une interface sont implicitement [public] et [abstract] : on ne l'écrit pas. Deux beans implémentent ce contrat : [MetierService], présenté plus bas, et [ApplicationModel] (dossier [web]), qui l'enrichit d'une mémoire des listes de médecins et de clients et délègue le reste à [MetierService] (cf. la section « La couche web : les services communs »). Les contrôleurs utilisent [ApplicationModel].
11.12.4. metier/DonneesPersonne.java
| // =========================================================================
// metier/DonneesPersonne.java
// -------------------------------------------------------------------------
// Les données saisies dans le formulaire "médecin" ou "client", telles que
// la couche web les transmet à la couche métier.
// =========================================================================
package rdvmedecins.metier;
public record DonneesPersonne(
String titre, // Mr, Mme, Melle
String nom,
String prenom,
// création : facultatif (null), vaut par défaut le nom en minuscules
// modification : facultatif, le login est inchangé s'il est absent
String login,
// création : facultatif, vaut par défaut le login
// modification : facultatif, le mot de passe est inchangé s'il est absent
String password,
// modification : la version de la fiche au moment où le formulaire a
// été affiché (verrou optimiste, cf. AbstractEntity.version)
Integer version) {
}
|
Les données d'un médecin ou d'un client à créer ou modifier, telles que la couche web les transmet (cf. exemple 28) : un record, dont les composants sont ici écrits un par ligne pour laisser la place aux commentaires (lignes 9-21). Titre, nom et prénom sont toujours fournis. Login et mot de passe (lignes 15 et 18) sont facultatifs et valent [null] quand ils n'ont pas été saisis : à la création, ils valent alors par défaut le nom en minuscules ; à la modification, ils restent inchangés. [version] (ligne 21) est la version de la fiche lue lors de l'affichage du formulaire de modification : elle sert au verrou optimiste. Son type est [Integer], et non [int], pour pouvoir valoir [null] (à la création, il n'y a pas de version).
11.12.5. metier/MetierService.java
C'est le fichier le plus riche de l'application. Nous le montrons en cinq extraits successifs.
11.12.5.1. Les descripteurs
| // =========================================================================
// metier/MetierService.java
// -------------------------------------------------------------------------
// Implémentation de la couche métier.
//
// La gestion des médecins et des clients obéit à trois règles de gestion :
//
// 1. une personne (médecin ou client) et son compte utilisateur vont
// TOUJOURS ensemble : on les crée, modifie et supprime ensemble, dans
// une même TRANSACTION - soit tout réussit, soit rien n'est écrit ;
// 2. le login est unique dans toute l'application, et deux médecins (ou
// deux clients) ne peuvent pas porter les mêmes prénom et nom ;
// 3. suppression :
// - un MÉDECIN qui a des rendez-vous ne peut pas être supprimé (ses
// patients perdraient leur rendez-vous sans être prévenus) : il faut
// d'abord les annuler. Ses créneaux horaires disparaissent avec lui ;
// - un CLIENT, lui, disparaît avec TOUS ses rendez-vous (passés et
// futurs) - que la suppression vienne de l'ADMIN ou du patient
// lui-même.
//
// Et deux règles pour la prise de rendez-vous, quel que soit le rôle de
// celui qui réserve (ADMIN, DOCTOR ou le patient lui-même) :
// 4. pas de rendez-vous dans le passé ;
// 5. un patient n'a qu'UN rendez-vous à venir à la fois.
//
// Médecins et clients suivent exactement les mêmes règles : le code commun
// est écrit UNE fois (méthodes privées génériques xxxPersonne), et paramétré
// par un petit "descripteur" qui dit à quel type de personne on a affaire.
//
// Les TRANSACTIONS sont ouvertes explicitement, par un TransactionTemplate
// (cf. la méthode transaction, en fin de fichier), pour pouvoir traduire en
// MetierException les erreurs que la base signale au moment du COMMIT.
// =========================================================================
package rdvmedecins.metier;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.IntConsumer;
import java.util.function.IntToLongFunction;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.domain.CreneauMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Personne;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.Rv;
import rdvmedecins.entities.User;
import rdvmedecins.repositories.ClientRepository;
import rdvmedecins.repositories.CreneauRepository;
import rdvmedecins.repositories.MedecinRepository;
import rdvmedecins.repositories.PersonneRepository;
import rdvmedecins.repositories.RvRepository;
import rdvmedecins.repositories.UserRepository;
// @Service : un bean de la couche métier ; une seule instance (singleton)
// pour toute l'application
@Service
public class MetierService implements Metier {
// tout ce qui distingue un médecin d'un client pour les règles de gestion
private record Descripteur<T extends Personne>(
PersonneRepository<T> dao,
Supplier<T> fabrique, // Medecin::new ou Client::new
Role role, // DOCTOR ou USER
String cleIntrouvable, // message si la personne n'existe pas
String cleExisteDeja, // message si les mêmes prénom et nom existent déjà
// facultatif (null) : si présent, la suppression est REFUSÉE tant que
// la personne a des rendez-vous (compterRv > 0), avec le message cleAvecRv
IntToLongFunction compterRv,
String cleAvecRv,
// facultatif : ce qu'il faut supprimer AVANT la personne (les lignes
// qui la référencent par une clé étrangère)
IntConsumer avantSuppression) {
}
private final MedecinRepository medecinRepository;
private final ClientRepository clientRepository;
private final CreneauRepository creneauRepository;
private final RvRepository rvRepository;
private final UserRepository userRepository;
private final PasswordEncoder hachage;
private final TransactionTemplate transactions;
private final Descripteur<Medecin> medecinDescripteur;
private final Descripteur<Client> clientDescripteur;
// toutes les dépendances sont fournies par l'injection de dépendances de
// Spring. TransactionTemplate : un bean fourni par Spring Boot, qui exécute
// du code dans une transaction.
public MetierService(MedecinRepository medecinRepository, ClientRepository clientRepository,
CreneauRepository creneauRepository, RvRepository rvRepository, UserRepository userRepository,
PasswordEncoder hachage, TransactionTemplate transactions) {
this.medecinRepository = medecinRepository;
this.clientRepository = clientRepository;
this.creneauRepository = creneauRepository;
this.rvRepository = rvRepository;
this.userRepository = userRepository;
this.hachage = hachage;
this.transactions = transactions;
medecinDescripteur = new Descripteur<>(
medecinRepository,
Medecin::new,
Role.DOCTOR,
"ERRORS.DOCTOR_NOT_FOUND",
"ERRORS.DOCTOR_ALREADY_EXISTS",
rvRepository::compterParMedecin,
"ERRORS.DOCTOR_HAS_APPOINTMENTS",
// un médecin sans rendez-vous peut encore avoir des créneaux horaires :
// ils n'ont plus de sens sans lui, on les supprime avec lui (sans quoi
// la clé étrangère creneaux.id_medecin interdirait la suppression)
creneauRepository::supprimerParMedecin);
clientDescripteur = new Descripteur<>(
clientRepository,
Client::new,
Role.USER,
"ERRORS.PATIENT_NOT_FOUND",
"ERRORS.PATIENT_ALREADY_EXISTS",
// pas de compterRv : la suppression d'un client n'est jamais refusée ;
// ses rendez-vous (passés et futurs) sont supprimés avec lui (sans quoi
// la clé étrangère rv.id_client interdirait la suppression)
null,
null,
rvRepository::supprimerParClient);
}
|
- lignes 6-24 : le commentaire d'en-tête résume les cinq règles de gestion appliquées par la classe ;
- lignes 50-52 : les exceptions de [Spring] qui signalent une erreur de la base, et un utilitaire pour les examiner (cf. le dernier extrait) ;
- ligne 53 : l'interface [PasswordEncoder] de [Spring Security], qui calcule les hachages [bcrypt] (cf. exemple 25). Le bean correspondant, un [BCryptPasswordEncoder] de coût 10 (le même que celui des mots de passe du script SQL), est déclaré dans [config/SecuriteConfig.java] (cf. la section « L'authentification ») ;
- ligne 55 : le [TransactionTemplate], qui exécute du code dans une transaction (cf. le dernier extrait) ;
- lignes 75-76 : [@Service] fait de la classe un bean de la couche métier. Comme tous les beans par défaut, c'est un singleton : une seule instance pour toute l'application, partagée par toutes les requêtes. Cela ne pose pas de problème, car l'objet ne garde aucune donnée propre à une requête : ses champs sont des dépendances, fixées une fois pour toutes. Le nom du bean est celui de la classe, avec une minuscule initiale : [metierService], le nom que cite [ApplicationModel] dans son [@Qualifier]. La classe implémente l'interface [Metier] : elle doit en fournir toutes les méthodes ;
- lignes 79-92 : tout ce qui distingue un médecin d'un client pour les règles de gestion : son repository, la façon de créer un objet vide, le rôle de son compte ([DOCTOR] ou [USER]), ses messages d'erreur, et ce qui se passe à sa suppression. Médecins et clients obéissant aux mêmes règles, le code de ces règles est écrit une seule fois, dans des méthodes génériques qui reçoivent un descripteur. Le descripteur est un record générique, déclaré à l'intérieur de la classe (et [private] : personne d'autre ne s'en sert) ;
- lignes 81, 87 et 91 : trois interfaces fonctionnelles du paquetage [java.util.function] : des interfaces à une seule méthode, qui décrivent le type d'une fonction. [Supplier<T>] est une fonction sans paramètre qui rend un [T] (méthode [get()]) ; [IntToLongFunction], une fonction qui reçoit un [int] et rend un [long] (méthode [applyAsLong]) ; [IntConsumer], une fonction qui reçoit un [int] et ne rend rien (méthode [accept]). Une valeur de ce type peut s'écrire sous la forme d'une lambda ([x -> ...]) ou d'une référence de méthode ([Classe::methode]) ;
- lignes 94-102 : les champs de la classe : les cinq repositories, le calculateur de hachages, le [TransactionTemplate] et les deux descripteurs. [final] interdit de les modifier après le constructeur ;
- lignes 107-109 : toutes les dépendances sont fournies par l'injection de dépendances de [Spring] : la classe n'a qu'un constructeur, [Spring] l'appelle avec les beans des types demandés. Les repositories sont les implémentations écrites par [Spring Data JPA], le [TransactionTemplate] est fabriqué par [Spring Boot] ;
- lignes 118-129 : le descripteur des médecins. [new Descripteur<>(...)] : le losange [<>] laisse le compilateur déduire le paramètre de type ([Medecin]) de la variable qui reçoit l'objet. Les valeurs sont données dans l'ordre des composants du record ;
- ligne 120 : [Medecin::new] est une référence de constructeur : une fonction sans paramètre qui rend un [new Medecin()]. Elle a le type [Supplier<Medecin>] attendu. Elle n'est pas appelée ici : elle le sera à chaque création d'un médecin ;
- ligne 124 : [rvRepository::compterParMedecin] est une référence de méthode : la méthode [compterParMedecin] de l'objet [rvRepository], qui reçoit un [int] et rend un [long], donc une valeur de type [IntToLongFunction]. La suppression d'un médecin sera refusée tant qu'il a des rendez-vous ;
- ligne 129 : de même, [creneauRepository::supprimerParMedecin] (un [int], pas de résultat) est un [IntConsumer] : les créneaux d'un médecin seront supprimés avant lui ;
- lignes 131-142 : le descripteur des clients. [Java] n'a pas de paramètres nommés ni facultatifs : les deux composants inutiles ([compterRv] et [cleAvecRv]) reçoivent [null] (lignes 140-141). La suppression d'un client n'est donc jamais refusée, et ses rendez-vous (passés et futurs) sont supprimés avant lui, sans quoi la clé étrangère [rv.id_client] interdirait la suppression.
11.12.5.2. Les lectures et l'agenda
| // =======================================================================
// lectures, hors transaction (chaque requête d'un DAO s'exécute seule,
// avec un EntityManager ouvert le temps de l'appel)
// =======================================================================
@Override
public List<Client> getAllClients() {
return clientRepository.findAllByOrderByNomAscPrenomAsc();
}
@Override
public List<Medecin> getAllMedecins() {
return medecinRepository.findAllByOrderByNomAscPrenomAsc();
}
@Override
public List<Creneau> getAllCreneaux(int idMedecin) {
return creneauRepository.findByIdMedecinOrderByHdebutAscMdebutAsc(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(int idMedecin, LocalDate jour) {
return rvRepository.rvMedecinJour(idMedecin, jour);
}
@Override
public Optional<Client> getClientById(int id) {
return clientRepository.findWithUserById(id);
}
@Override
public Optional<Medecin> getMedecinById(int id) {
return medecinRepository.findWithUserById(id);
}
@Override
public Optional<Rv> getRvById(int id) {
return rvRepository.lire(id);
}
@Override
public Optional<Creneau> getCreneauById(int id) {
return creneauRepository.findWithMedecinById(id);
}
@Override
public Optional<Client> getClientByUserId(int idUser) {
return clientRepository.findByIdUser(idUser);
}
@Override
public List<Rv> getRvAVenirClient(int idClient) {
return rvRepository.rvAVenirClient(idClient, Dates.aujourdhui());
}
// même algorithme que le serveur JSON (et que le projet Spring d'origine)
@Override
public Optional<AgendaMedecinJour> getAgendaMedecinJour(int idMedecin, LocalDate jour) {
Optional<Medecin> medecin = getMedecinById(idMedecin);
if (medecin.isEmpty()) {
return Optional.empty();
}
List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
// n° de créneau -> rendez-vous pris sur ce créneau
Map<Integer, Rv> reservations = getRvMedecinJour(idMedecin, jour).stream()
.collect(Collectors.toMap(Rv::getIdCreneau, Function.identity()));
List<CreneauMedecinJour> creneauxMedecinJour = creneauxHoraires.stream()
.map(creneau -> new CreneauMedecinJour(creneau, reservations.get(creneau.getId())))
.toList();
return Optional.of(new AgendaMedecinJour(medecin.get(), jour, creneauxMedecinJour));
}
|
- lignes 6-54 : les lectures délèguent simplement à la couche DAO, en traduisant les noms de méthodes des repositories (souvent imposés par [Spring Data JPA]) en noms métier. [@Override] signale une méthode qui implémente une méthode de l'interface : le compilateur vérifie qu'elle existe bien dans [Metier]. Ces méthodes n'ouvrent pas de transaction : chaque requête s'exécute seule, avec un [EntityManager] créé pour la durée de l'appel (cf. la section « JPA, Hibernate et Spring Data »). Chacune charge les relations dont les pages ont besoin (le compte d'une personne, le médecin d'un créneau, le client et le créneau d'un rendez-vous) : les entités rendues sont détachées, et les contrôleurs et les vues peuvent lire ces relations sans risque ;
- ligne 53 : les rendez-vous à venir d'un patient : la couche métier fournit la date du jour au repository ;
- lignes 58-71 : le calcul de l'agenda d'un médecin pour un jour. Un médecin inconnu donne un [Optional] vide ([Optional.empty()]) ; sinon on lit ses créneaux et les rendez-vous du jour. L'agenda fait trois lectures successives, indépendantes les unes des autres : c'est acceptable pour une page affichée, que l'utilisateur rechargera s'il le faut ;
- lignes 65-66 : les rendez-vous du jour sont rangés dans un dictionnaire ([Map]) dont la clé est l'identifiant de leur créneau. [stream()] transforme la liste en un flux (*stream*) : une suite d'éléments que l'on traite par des opérations enchaînées. [collect(Collectors.toMap(...))] range les éléments du flux dans un dictionnaire : la clé de chaque rendez-vous est calculée par [Rv::getIdCreneau] (une référence de méthode, appliquée à chaque rendez-vous), et la valeur par [Function.identity()], la fonction qui rend son argument : le rendez-vous lui-même. [toMap] échouerait sur deux clés égales, mais cela ne peut pas arriver : la contrainte [unq1_rv] interdit deux rendez-vous sur le même créneau le même jour. Le dictionnaire évite de parcourir toute la liste des rendez-vous pour chaque créneau ;
- lignes 67-69 : chaque créneau est associé à son rendez-vous, ou à [null] : [get] rend [null] quand la clé est absente, c'est-à-dire quand le créneau est libre. [map] transforme chaque élément du flux par la lambda [creneau -> new CreneauMedecinJour(...)] : une fonction anonyme, dont le paramètre [creneau] est écrit à gauche de la flèche et le résultat à droite. [toList()] recueille le résultat dans une liste non modifiable. L'ordre chronologique des créneaux, fixé par la requête, est conservé ;
- ligne 70 : l'agenda, dans un [Optional] plein ([Optional.of]) ; [medecin.get()] extrait le médecin de son [Optional], dont on sait ici qu'il n'est pas vide. Le résultat est un objet [AgendaMedecinJour] du dossier [domain], que la vue de l'agenda affichera.
11.12.5.3. La prise de rendez-vous
| // =======================================================================
// rendez-vous
// =======================================================================
// -----------------------------------------------------------------------
// prise de rendez-vous, dans une transaction :
// 1. pas de rendez-vous dans le passé ;
// 2. on VERROUILLE la ligne du patient (SELECT ... FOR UPDATE) : si le
// même patient réserve deux créneaux au même instant (deux onglets,
// double clic...), la seconde transaction attend la fin de la
// première - et voit alors le rendez-vous qu'elle vient de créer ;
// 3. un seul rendez-vous à venir par patient ;
// 4. enfin l'insertion. Si le créneau vient d'être pris par un AUTRE
// patient, c'est la contrainte SQL unq1_rv (jour, id_creneau) qui
// refuse l'insertion - traduite en MetierException par [transaction].
// -----------------------------------------------------------------------
@Override
public Rv ajouterRv(LocalDate jour, Creneau creneau, Client client) {
return transaction(() -> {
LocalDate aujourdhui = Dates.aujourdhui();
if (jour.isBefore(aujourdhui)) {
throw new MetierException("ERRORS.PAST_DATE");
}
int idPatient = clientRepository.verrouiller(client.getId())
.orElseThrow(() -> new MetierException("ERRORS.PATIENT_NOT_FOUND"));
if (rvRepository.countByIdClientAndJourGreaterThanEqual(idPatient, aujourdhui) > 0) {
throw new MetierException("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
}
// getReferenceById : une "référence" vers le créneau et le patient,
// sans les relire en base - il suffit de connaître leur id pour
// écrire les clés étrangères
Creneau refCreneau = creneauRepository.getReferenceById(creneau.getId());
Client refPatient = clientRepository.getReferenceById(idPatient);
return rvRepository.save(new Rv(jour, refCreneau, refPatient));
});
}
@Override
public void supprimerRv(Rv rv) {
transaction(() -> {
rvRepository.supprimer(rv.getId());
return null;
});
}
|
- lignes 19 et 35 : toute la réservation se passe dans une transaction : la méthode passe à [transaction] (cf. le dernier extrait) une lambda sans paramètre, [() -> { ... }], qui fait le travail et rend le rendez-vous créé. Une lambda peut utiliser les paramètres de la méthode qui l'entoure ([jour], [creneau], [client]), à condition qu'ils ne soient jamais modifiés ;
- lignes 21-23 : pas de rendez-vous dans le passé. La règle est vérifiée ici quel que soit le rôle de celui qui réserve (ADMIN, DOCTOR ou le patient), même si la couche web a déjà masqué les boutons Réserver des jours passés : une requête peut être fabriquée à la main ;
- lignes 24-25 : la ligne du patient est verrouillée par [SELECT ... FOR UPDATE] (cf. [ClientRepository]). C'est un verrou pessimiste : il est posé avant de travailler, en supposant le conflit possible, et il dure jusqu'à la fin de la transaction. Sans lui, deux réservations du même patient envoyées au même instant (deux onglets, un double clic) pourraient passer toutes deux le test suivant, avant que l'une ou l'autre n'ait écrit son rendez-vous. Avec lui, la seconde transaction attend la fin de la première, puis voit le rendez-vous qu'elle a créé. Un patient introuvable (supprimé entre-temps) est refusé : [orElseThrow] rend le contenu de l'[Optional], ou lève l'exception fabriquée par la lambda s'il est vide ;
- lignes 26-28 : un seul rendez-vous à venir par patient ;
- lignes 32-33 : [getReferenceById] rend une référence vers une entité : un mandataire qui ne contient que l'identifiant, sans lire la ligne en base. Pour écrire les clés étrangères de la ligne [rv], il suffit de connaître les identifiants du créneau et du patient : inutile de les relire. On n'utilise pas directement les objets [creneau] et [client] reçus en paramètres : ils viennent de la couche web, sont détachés, et ne font pas partie du contexte de persistance de cette transaction ;
- ligne 34 : l'insertion. [save] enregistre le nouveau rendez-vous (clé [IDENTITY] : l'[INSERT] part aussitôt) et le rend. Si un autre patient vient de prendre le même créneau, le verrou ne l'a pas arrêté (il porte sur la ligne du patient) : c'est la contrainte d'unicité [unq1_rv (jour, id_creneau)] de la base qui refuse l'insertion, et [transaction] traduit l'erreur en [MetierException] avec la clé [ERRORS.SLOT_ALREADY_BOOKED] ;
- lignes 39-44 : l'annulation d'un rendez-vous, sans règle particulière. La méthode [supprimer] du repository est une requête [@Modifying] : elle a besoin d'une transaction qui accepte les écritures, d'où l'appel à [transaction]. La lambda doit rendre une valeur : elle rend [null], que personne n'utilise.
Voici les ordres SQL envoyés lors d'une réservation, relevés dans la console. Le premier lit le créneau et son médecin ([getCreneauById], appelée par le contrôleur avant [ajouterRv]) ; les trois suivants sont ceux de la transaction : le verrou, le comptage, l'insertion. Grâce à [getReferenceById], ni le créneau ni le client ne sont relus :
| Hibernate: select c1_0.id,c1_0.hdebut,c1_0.hfin,c1_0.id_medecin,c1_0.mdebut,m1_0.id,m1_0.`user`,m1_0.nom,m1_0.prenom,m1_0.titre,m1_0.version,c1_0.mfin,c1_0.version from creneaux c1_0 left join medecins m1_0 on m1_0.id=c1_0.id_medecin where c1_0.id=?
Hibernate: select id from clients where id = ? for update
Hibernate: select count(r1_0.id) from rv r1_0 where r1_0.id_client=? and r1_0.jour>=?
Hibernate: insert into rv (id_client,id_creneau,jour,version) values (?,?,?,?)
|
L'[INSERT] écrit aussi la colonne [version] : [Hibernate] donne la version 0 à toute nouvelle entité.
11.12.5.4. La gestion des médecins et des clients
| // =======================================================================
// médecins et clients - les méthodes publiques délèguent aux méthodes
// privées communes, avec le bon descripteur
// =======================================================================
@Override
public Medecin ajouterMedecin(DonneesPersonne donnees) {
return ajouterPersonne(medecinDescripteur, donnees);
}
@Override
public Medecin modifierMedecin(int id, DonneesPersonne donnees) {
return modifierPersonne(medecinDescripteur, id, donnees);
}
@Override
public void supprimerMedecin(int id) {
supprimerPersonne(medecinDescripteur, id);
}
@Override
public Client ajouterClient(DonneesPersonne donnees) {
return ajouterPersonne(clientDescripteur, donnees);
}
@Override
public Client modifierClient(int id, DonneesPersonne donnees) {
return modifierPersonne(clientDescripteur, id, donnees);
}
// le client, son compte ET tous ses rendez-vous (ADMIN, ou le patient
// lui-même depuis "Gérer mon compte" : même méthode, même règle)
@Override
public void supprimerClient(int id) {
supprimerPersonne(clientDescripteur, id);
}
// -----------------------------------------------------------------------
// création : le compte utilisateur d'abord (la personne le référence),
// puis la personne - le tout dans UNE transaction.
// -----------------------------------------------------------------------
private <T extends Personne> T ajouterPersonne(Descripteur<T> d, DonneesPersonne donnees) {
String nom = donnees.nom().trim().toUpperCase(Locale.ROOT);
String prenom = donnees.prenom().trim();
// les règles de la base dbrdvmedecins2 : login = nom en minuscules,
// mot de passe = login - sauf si l'ADMIN en a saisi d'autres
String login = (vide(donnees.login()) ? nom : donnees.login()).toLowerCase(Locale.ROOT);
String password = vide(donnees.password()) ? login : donnees.password();
return transaction(() -> {
verifierPersonneUnique(d, prenom, nom, null);
verifierLoginLibre(login);
User user = new User();
user.setLogin(login);
user.setPassword(hachage.encode(password));
user.setNom(prenom + " " + nom);
user.setRole(d.role());
userRepository.save(user);
T personne = d.fabrique().get();
personne.setTitre(donnees.titre());
personne.setNom(nom);
personne.setPrenom(prenom);
personne.setUser(user);
return d.dao().save(personne);
});
}
// -----------------------------------------------------------------------
// modification : la personne ET son compte (le nom affiché "prénom NOM"
// doit suivre ; le login et le mot de passe ne changent que s'ils ont été
// saisis).
// -----------------------------------------------------------------------
private <T extends Personne> T modifierPersonne(Descripteur<T> d, int id, DonneesPersonne donnees) {
return transaction(() -> {
T personne = d.dao().findWithUserById(id).orElseThrow(() -> new MetierException(d.cleIntrouvable()));
// verrou optimiste : si la version en base n'est plus celle du
// formulaire, quelqu'un a modifié cette fiche entre-temps - on refuse
// plutôt que d'écraser silencieusement ses modifications.
// (Si la modification concurrente a lieu APRÈS cette lecture, c'est
// la clause "WHERE version = ..." de l'UPDATE qui la détecte.)
if (donnees.version() != null && !donnees.version().equals(personne.getVersion())) {
throw new MetierException("ERRORS.CONCURRENT_UPDATE");
}
String nom = donnees.nom().trim().toUpperCase(Locale.ROOT);
String prenom = donnees.prenom().trim();
verifierPersonneUnique(d, prenom, nom, id);
// on se contente de modifier les objets : l'entité lue dans la
// transaction est "gérée" par JPA, qui enverra lui-même les UPDATE
// nécessaires au COMMIT (pas besoin d'appeler save)
personne.setTitre(donnees.titre());
personne.setNom(nom);
personne.setPrenom(prenom);
User user = personne.getUser();
user.setNom(prenom + " " + nom);
if (!vide(donnees.login()) && !donnees.login().toLowerCase(Locale.ROOT).equals(user.getLogin())) {
String login = donnees.login().toLowerCase(Locale.ROOT);
verifierLoginLibre(login);
user.setLogin(login);
}
if (!vide(donnees.password())) {
user.setPassword(hachage.encode(donnees.password()));
}
return personne;
});
}
// -----------------------------------------------------------------------
// suppression, dans UNE transaction, dans l'ordre imposé par les clés
// étrangères :
// 1. refus éventuel s'il reste des rendez-vous (médecin) ;
// 2. ce qui référence la personne (créneaux d'un médecin, rendez-vous
// d'un client) ;
// 3. la personne (elle référence son compte) ;
// 4. son compte.
// -----------------------------------------------------------------------
private <T extends Personne> void supprimerPersonne(Descripteur<T> d, int id) {
transaction(() -> {
T personne = d.dao().findById(id).orElseThrow(() -> new MetierException(d.cleIntrouvable()));
if (d.compterRv() != null && d.compterRv().applyAsLong(id) > 0) {
throw new MetierException(d.cleAvecRv());
}
if (d.avantSuppression() != null) {
d.avantSuppression().accept(id);
}
d.dao().supprimer(id);
userRepository.supprimer(personne.getIdUser());
return null; // [transaction] attend un résultat : on n'en a pas besoin ici
});
}
// refuse des prénom et nom déjà portés par une AUTRE personne du même type
// (idCourant : la personne en cours de modification, qui a évidemment le
// droit de garder ses propres prénom et nom ; null pour une création)
private <T extends Personne> void verifierPersonneUnique(Descripteur<T> d, String prenom, String nom,
Integer idCourant) {
Optional<T> homonyme = d.dao().findFirstByPrenomAndNom(prenom, nom);
if (homonyme.isPresent() && !Objects.equals(homonyme.get().getId(), idCourant)) {
throw new MetierException(d.cleExisteDeja());
}
}
private void verifierLoginLibre(String login) {
if (userRepository.findByLogin(login).isPresent()) {
throw new MetierException("ERRORS.LOGIN_TAKEN");
}
}
private static boolean vide(String texte) {
return texte == null || texte.isEmpty();
}
|
- lignes 6-36 : les six méthodes publiques délèguent aux trois méthodes génériques privées, avec le bon descripteur. Le compilateur déduit [T] du descripteur : [Medecin] pour [medecinDescripteur], [Client] pour [clientDescripteur] ;
- ligne 42 : [<T extends Personne>], écrit avant le type du résultat, fait de [ajouterPersonne] une méthode générique : elle a son propre paramètre de type [T], qui doit être [Personne] ou une classe fille ;
- lignes 43-48 : la préparation des données, hors transaction. [trim()] retire les espaces en début et en fin de chaîne ; le nom est mis en majuscules. [Locale.ROOT] demande une conversion indépendante de la langue du serveur (en turc, par exemple, la minuscule de [I] n'est pas [i]). Le login vaut par défaut le nom, et il est toujours mis en minuscules ; le mot de passe vaut par défaut le login : ce sont les règles des comptes du script SQL. [a ? b : c] est l'opérateur conditionnel : [b] si [a] est vrai, [c] sinon ;
- lignes 51-52 : dans la transaction, les deux règles d'unicité : pas d'homonyme, login libre. Elles sont vérifiées avant l'écriture pour donner un message clair (cf. exemple 28) ;
- lignes 54-59 : le compte utilisateur est créé d'abord, car la personne le référence (clé étrangère). Le mot de passe n'est jamais enregistré en clair : [hachage.encode] en calcule le hachage [bcrypt]. [save] envoie aussitôt l'[INSERT] (clé [IDENTITY]) : après cet appel, [user] a un identifiant, et c'est une entité gérée ;
- lignes 61-66 : puis la personne. [d.fabrique().get()] appelle la référence de constructeur du descripteur : [new Medecin()] ou [new Client()]. [setUser(user)] relie la personne à son compte : à l'[INSERT] de la personne, [Hibernate] écrira l'identifiant du compte dans la colonne [user]. Si cette seconde écriture échoue, la transaction annule aussi la première : on n'obtient jamais un compte sans personne ;
- lignes 75-111 : la modification de la personne et de son compte, dans une transaction ;
- ligne 77 : la personne et son compte ([findWithUserById]), ou une [MetierException] avec la clé du descripteur si elle n'existe pas ;
- lignes 84-86 : le verrou optimiste (cf. exemple 28) : le formulaire a transporté la version de la fiche lue à son affichage ; si la version en base est différente, quelqu'un a modifié la fiche entre-temps, et on refuse plutôt que d'écraser silencieusement son travail. Contrairement au verrou pessimiste de la réservation, rien n'est bloqué : on suppose que les conflits sont rares, et on se contente de les détecter. Les versions sont des [Integer], des objets : on les compare avec [equals], et non avec [!=], qui comparerait les références des objets. Si la modification concurrente a lieu après cette lecture, c'est la condition [WHERE version = ...] de l'[UPDATE] qui la détectera (cf. le dernier extrait) ;
- lignes 88-90 : les nouveaux nom et prénom, et la vérification des homonymes. Elle transmet l'identifiant de la personne : celle-ci a évidemment le droit de garder ses propres prénom et nom ;
- lignes 95-97 : on modifie simplement l'objet lu. Lu dans la transaction, il est géré par l'[EntityManager], qui enverra lui-même l'[UPDATE] au moment du [COMMIT], en comparant l'objet à l'état lu (cf. la section « JPA, Hibernate et Spring Data ») : il n'y a pas de [save] à appeler. L'[UPDATE] augmentera la version et vérifiera l'ancienne ;
- lignes 99-108 : le compte suit la personne. Il a été chargé avec elle ([@EntityGraph]), il est géré lui aussi : son nom affiché est recalculé, son login ne change que s'il a été saisi et diffère de l'actuel (il doit alors être libre), son mot de passe ne change que s'il a été saisi, et il est haché à son tour ;
- ligne 109 : la personne modifiée est rendue ; après le [COMMIT], elle est détachée, mais son compte, chargé, reste lisible par le contrôleur ;
- lignes 122-135 : la suppression, dans une transaction, dans l'ordre imposé par les clés étrangères ;
- ligne 124 : [findById] suffit : on n'a pas besoin du compte, seulement de son identifiant, que donne le champ [idUser] sans charger le compte ;
- lignes 125-127 : le refus éventuel : un médecin qui a des rendez-vous ne peut pas être supprimé (ses patients perdraient leur rendez-vous sans être prévenus). [d.compterRv().applyAsLong(id)] appelle la fonction rangée dans le descripteur, c'est-à-dire [rvRepository.compterParMedecin(id)] ;
- lignes 128-130 : la suppression de ce qui référence la personne : les créneaux d'un médecin, les rendez-vous d'un client ;
- lignes 131-132 : la personne, puis son compte, que la personne référençait ;
- ligne 133 : [transaction] attend une fonction qui rend un résultat : on rend [null], dont personne ne se servira ;
- lignes 140-146 : un homonyme est refusé, sauf s'il s'agit de la personne en cours de modification ([idCourant], [null] pour une création). [Objects.equals(a, b)] compare deux objets en acceptant [null] : [homonyme.get().getId().equals(null)] fonctionnerait aussi, mais [Objects.equals] évite de se demander lequel des deux peut être [null] ;
- lignes 148-152 : un login déjà pris, par un médecin, un client ou l'administrateur, est refusé ;
- lignes 154-156 : une chaîne absente ([null]) ou vide : c'est ainsi qu'arrive un champ facultatif non saisi.
On remarque que seules les créations appellent [save]. Une modification se contente de modifier des entités gérées, et une suppression passe par des requêtes [delete] : c'est la manière habituelle de travailler avec [JPA].
11.12.5.5. Les transactions et les contraintes d'unicité
| // -----------------------------------------------------------------------
// exécute [travail] dans une transaction :
// transactions.execute(...) -> START TRANSACTION, puis le travail, puis
// COMMIT si tout s'est bien passé ; si une exception est levée
// (MetierException comprise), ROLLBACK : rien n'est écrit.
//
// Les contrôles "login libre" et "prénom + nom uniques" sont faits AVANT
// l'écriture, pour donner un message clair. Mais deux ADMIN pourraient
// enregistrer le même login (ou le même médecin) au même instant : dans ce
// cas rare, c'est une contrainte d'unicité SQL qui refuse la seconde
// écriture - au moment où Hibernate envoie les ordres SQL, souvent au
// COMMIT, donc APRÈS la fin du travail. C'est pourquoi on intercepte les
// exceptions ici, AUTOUR de la transaction, pour les traduire en la même
// MetierException.
// -----------------------------------------------------------------------
private <R> R transaction(Supplier<R> travail) {
try {
return transactions.execute(statut -> travail.get());
} catch (MetierException e) {
throw e;
} catch (RuntimeException e) {
MetierException erreurMetier = traduireErreurBase(e);
if (erreurMetier != null) {
throw erreurMetier;
}
throw e; // une vraie anomalie : on la laisse remonter
}
}
// -----------------------------------------------------------------------
// Erreurs de la base -> MetierException
//
// Spring traduit les exceptions de Hibernate et du pilote JDBC en une
// hiérarchie d'exceptions indépendante de la base (DataAccessException) :
// - OptimisticLockingFailureException : l'UPDATE "WHERE id = ... AND
// version = ..." n'a modifié aucune ligne (verrou optimiste) ;
// - DataIntegrityViolationException : une contrainte de la base a refusé
// l'écriture. Pour une contrainte UNIQUE, le message d'erreur de MySQL
// (la cause "la plus profonde" de l'exception) cite le NOM de la contrainte :
// Duplicate entry 'marie-PELISSIER' for key 'medecins.unq_medecins_prenom_nom'
// (MySQL 8 préfixe le nom par celui de la table, MariaDB non). C'est pour
// cela que le script SQL NOMME toutes ses contraintes.
// Toute autre erreur est une vraie anomalie : on renvoie null (non traduite).
// -----------------------------------------------------------------------
private static final Map<String, String> MESSAGES_CONTRAINTES = Map.of(
"unq_users_login", "ERRORS.LOGIN_TAKEN",
"unq_medecins_prenom_nom", "ERRORS.DOCTOR_ALREADY_EXISTS",
"unq_clients_prenom_nom", "ERRORS.PATIENT_ALREADY_EXISTS",
"unq1_rv", "ERRORS.SLOT_ALREADY_BOOKED");
private static final Pattern CONTRAINTE = Pattern.compile("for key '(?:[^'.]*\\.)?([^']+)'");
private static MetierException traduireErreurBase(RuntimeException e) {
if (e instanceof OptimisticLockingFailureException) {
return new MetierException("ERRORS.CONCURRENT_UPDATE");
}
if (e instanceof DataIntegrityViolationException) {
String message = String.valueOf(NestedExceptionUtils.getMostSpecificCause(e).getMessage());
Matcher correspondance = CONTRAINTE.matcher(message);
if (correspondance.find() && MESSAGES_CONTRAINTES.containsKey(correspondance.group(1))) {
return new MetierException(MESSAGES_CONTRAINTES.get(correspondance.group(1)));
}
}
return null;
}
}
|
- ligne 16 : toutes les écritures passent par cette méthode générique : [R] est le type du résultat du travail ([Rv], [Medecin], [Client], ou [Object] quand la lambda rend [null]), et [Supplier<R>] le type d'une fonction sans paramètre qui rend un [R] ;
- ligne 18 : [transactions.execute(...)] ouvre une transaction ([START TRANSACTION]), exécute la lambda qu'on lui passe, puis valide la transaction ([COMMIT]) et rend le résultat de la lambda. Si la lambda lève une exception non vérifiée, [MetierException] comprise, la transaction est annulée ([ROLLBACK]) et l'exception relancée. La lambda reçoit en paramètre ([statut]) l'état de la transaction, qui permettrait de demander une annulation sans lever d'exception ; on ne s'en sert pas : elle se contente d'appeler [travail.get()] ;
- lignes 19-20 : une [MetierException] (une règle de gestion refusée) repasse telle quelle jusqu'au contrôleur. Ce bloc doit précéder le suivant : [MetierException] est elle-même une [RuntimeException], et [Java] essaie les blocs [catch] dans l'ordre ;
- lignes 21-26 : toute autre exception est soumise à [traduireErreurBase] : si c'est une erreur de la base connue, on lève à sa place la [MetierException] correspondante ; sinon, c'est une vraie anomalie, relancée telle quelle, qui produira la page d'erreur 500 ;
- lignes 45-49 : à chaque contrainte d'unicité nommée du script SQL, sa clé de traduction. C'est pour cela que le script SQL nomme toutes ses contraintes. [Map.of(k1, v1, k2, v2...)] fabrique un dictionnaire non modifiable ; [static final] en fait une constante de la classe, créée une seule fois ;
- ligne 51 : une expression régulière, compilée une fois pour toutes par [Pattern.compile]. Elle extrait le nom de la contrainte du message d'erreur de la base (cf. plus bas) ;
- lignes 53-65 : la traduction d'une erreur de la base en [MetierException], ou [null] si l'erreur n'est pas reconnue ;
- lignes 54-56 : l'[UPDATE ... WHERE id = ... AND version = ...] n'a modifié aucune ligne : quelqu'un a modifié la fiche entre la lecture et l'enregistrement (verrou optimiste). [instanceof] teste le type réel d'un objet. L'exception reçue est une [ObjectOptimisticLockingFailureException], fille de [OptimisticLockingFailureException] ;
- lignes 57-63 : une contrainte de la base a refusé l'écriture ;
- ligne 58 : l'exception de [Spring] enveloppe celle de [Hibernate], qui enveloppe elle-même celle du pilote [MySQL] : chaque exception garde la précédente comme cause ([getCause()]). [NestedExceptionUtils.getMostSpecificCause(e)] descend jusqu'à la cause la plus profonde, celle du pilote, dont le message est celui de [MySQL]. [String.valueOf] protège d'un message [null] (il donne alors la chaîne ["null"]) ;
- lignes 59-61 : on cherche le nom de la contrainte dans ce message, et on le traduit s'il figure dans le dictionnaire. [find()] cherche l'expression régulière dans le message ; [group(1)] rend ce qu'a capturé le premier groupe entre parenthèses. Toute autre erreur, y compris la violation d'une contrainte absente du dictionnaire, est rendue non traduite : c'est une vraie anomalie.
Le message d'erreur d'une contrainte d'unicité violée cite le nom de la contrainte. Voici celui que produit [MariaDB] quand on insère deux fois les mêmes prénom et nom (la collation ne tient pas compte des majuscules : [marie pelissier] est un doublon de [Marie PELISSIER]) :
| ERROR 1062 (23000) at line 1: Duplicate entry 'marie-pelissier' for key 'unq_medecins_prenom_nom'
|
[MySQL] 8 écrit, lui, [for key 'medecins.unq_medecins_prenom_nom'], avec le nom de la table en préfixe. L'expression régulière [for key '(?:[^'.]*\.)?([^']+)'] accepte les deux formes : [(?:[^'.]*\.)?] saute un préfixe facultatif terminé par un point (un groupe [(?:...)] qui ne capture rien), et le groupe [([^']+)] capture le nom jusqu'à l'apostrophe. Dans la chaîne [Java], le point est écrit [\\.] : la barre oblique inverse est doublée, car elle est elle-même un caractère d'échappement dans une chaîne [Java].
Reste à expliquer pourquoi l'application utilise un [TransactionTemplate] plutôt que l'annotation [@Transactional] de l'exemple 28. Il y a deux raisons :
- une erreur de la base ne se révèle pas toujours dans le travail lui-même. L'[INSERT] d'une nouvelle entité part tout de suite (clé [IDENTITY]) : une contrainte d'unicité violée par une création lève son exception pendant [save], dans la lambda. Mais l'[UPDATE] d'une entité modifiée n'est envoyé qu'au moment du [COMMIT] (cf. [modifierPersonne]) : c'est alors seulement que la base signale un login déjà pris ou une version périmée. Avec [@Transactional], le [COMMIT] a lieu après le retour de la méthode, dans le code de [Spring] qui l'enveloppe : la méthode ne peut pas intercepter les erreurs qu'il provoque. L'exemple 28 contournait la difficulté en appelant [saveAndFlush], qui force l'envoi immédiat des ordres SQL. Avec [transactions.execute(...)], le [COMMIT] a lieu à l'intérieur de la méthode [transaction] : son [try] voit passer toutes les erreurs, qu'elles surviennent pendant le travail ou pendant le [COMMIT] ;
- [@Transactional] fonctionne par un mandataire : [Spring] enveloppe le bean dans un objet qui ouvre la transaction avant d'appeler la méthode. Un appel d'une méthode à une autre du même objet ne passe pas par ce mandataire : placée sur la méthode privée [ajouterPersonne], appelée par [ajouterMedecin], l'annotation serait sans effet. Le [TransactionTemplate] n'a pas cette limite : c'est un appel de méthode ordinaire.
Enfin, en cas d'échec, il n'y a rien à nettoyer : le contexte de persistance est lié à la transaction, et disparaît avec elle au [ROLLBACK]. La version [ASP.NET Core] devait, elle, vider le suivi des modifications de son [DbContext], qui vivait toute la requête.
11.13. L'authentification
Le dossier [auth] reprend, en plus complet, les exemples 25 à 27 : jeton JWT dans un cookie, rôles, captcha, limitation des tentatives. S'y ajoute un « laissez-passer » pour la création d'un compte client. La configuration de [Spring Security] elle-même, qui assemble ces pièces, est rangée avec les autres classes de configuration, dans le fichier [config/SecuriteConfig.java] : c'est par lui que nous commençons. Le contrôleur [AuthController], commenté à la fin de cette section, sert la page de connexion.
11.13.1. config/SecuriteConfig.java
Le fichier est présenté en deux extraits. Le premier déclare les beans de la cryptographie : la clé, la fabrication et la vérification des jetons, le hachage des mots de passe.
| // =========================================================================
// config/SecuriteConfig.java
// -------------------------------------------------------------------------
// La configuration de Spring Security (cf. chapitre 8) : qui peut accéder à
// quoi, et comment l'utilisateur est reconnu à chaque requête.
//
// Spring Security est une CHAÎNE DE FILTRES de servlet, placée devant Spring
// MVC. Pour chaque requête :
// 1. le filtre du "resource server" OAuth2 cherche un jeton JWT (ici, dans
// le cookie rdvmedecins_jwt), vérifie sa signature et sa date
// d'expiration, et en tire l'utilisateur (Authentication) ;
// 2. le filtre d'autorisation applique les règles d'accès aux URL ;
// 3. ensuite seulement, Spring MVC appelle l'action, dont les annotations
// @PreAuthorize (activées par @EnableMethodSecurity) vérifient le rôle.
// Deux cas d'échec, deux réponses adaptées à une application HTML :
// - pas d'utilisateur authentifié (pas de jeton, jeton expiré ou falsifié)
// -> redirection vers la page de connexion ;
// - rôle insuffisant -> réponse 403, dont ErreurController fait une page.
// =========================================================================
package rdvmedecins.config;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder.BCryptVersion;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.auth.AuthService;
import rdvmedecins.auth.UtilisateurConnecte;
import rdvmedecins.web.Flash;
import rdvmedecins.web.MessageFlash;
@Configuration
@EnableMethodSecurity
public class SecuriteConfig {
// la clé qui signe et vérifie les jetons (algorithme symétrique HMAC-SHA256)
@Bean
public SecretKey cleJwt(AuthProprietes auth) {
byte[] octets = auth.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (octets.length < 32) {
// HMAC-SHA256 exige une clé de 256 bits au moins : on le dit
// clairement au démarrage plutôt qu'à la première connexion
throw new IllegalStateException(
"rdvmedecins.auth.jwt-secret doit comporter au moins 32 caractères (cf. application.properties).");
}
return new SecretKeySpec(octets, "HmacSHA256");
}
// la fabrication des jetons (cf. auth/AuthService.java)
@Bean
public JwtEncoder jwtEncoder(SecretKey cle) {
return NimbusJwtEncoder.withSecretKey(cle).algorithm(MacAlgorithm.HS256).build();
}
// la vérification des jetons : signature et date d'expiration
@Bean
public JwtDecoder jwtDecoder(SecretKey cle) {
return NimbusJwtDecoder.withSecretKey(cle).macAlgorithm(MacAlgorithm.HS256).build();
}
// le hachage des mots de passe : bcrypt, 10 tours, version $2b$ - comme
// les hachages du script SQL
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(BCryptVersion.$2B, 10);
}
|
Commentons ce code :
- lignes 4-18 : le commentaire d'en-tête rappelle le principe vu au chapitre 8 : [Spring Security] est une chaîne de filtres de servlet, exécutés avant que la requête n'atteigne [Spring MVC]. Un filtre reconnaît l'utilisateur à partir du jeton, un autre applique les règles d'accès aux URL, puis l'annotation [@PreAuthorize] de chaque action vérifie le rôle ;
- ligne 54 : [@Configuration] fait de la classe une source de beans : chacune de ses méthodes annotées [@Bean] est appelée une fois, au démarrage, et l'objet qu'elle rend devient un bean singleton, que [Spring] injecte partout où il est demandé ;
- ligne 55 : [@EnableMethodSecurity] active la vérification des annotations [@PreAuthorize("hasRole('ADMIN')")] placées sur les contrôleurs (cf. exemple 26). Sans elle, ces annotations seraient ignorées, sans le moindre message ;
- lignes 59-69 : la clé secrète qui signe et vérifie les jetons (cf. exemple 25). Le paramètre [AuthProprietes auth] est le record qui donne une forme typée aux propriétés [rdvmedecins.auth.*] de [application.properties] (cf. « La configuration du projet ») : [Spring] le fournit comme n'importe quel autre bean. Nouveauté par rapport à l'exemple 25 : une clé trop courte est refusée. L'algorithme HMAC-SHA256 exige une clé de 256 bits, soit 32 octets ; la bibliothèque [Nimbus], qui fabrique les jetons, ne le vérifierait qu'à la fabrication du premier jeton, c'est-à-dire à la première connexion. L'exception levée ici, pendant la création des beans, empêche au contraire l'application de démarrer, avec un message qui dit exactement quoi corriger ;
- lignes 72-81 : la fabrication ([JwtEncoder]) et la vérification ([JwtDecoder]) des jetons, avec la même clé : l'algorithme est symétrique. Le décodeur vérifie la signature et la date d'expiration (claim [exp]). Il accorde, par défaut, une tolérance de soixante secondes pour les écarts d'horloge entre serveurs : un jeton reste accepté une minute après son expiration ;
- lignes 85-88 : le hachage des mots de passe, par l'algorithme [bcrypt] (cf. exemple 25). Deux paramètres cette fois : le coût (10 : l'algorithme fait 2^10 tours de calcul, ce qui rend coûteuse l'attaque qui consiste à essayer des millions de mots de passe) et la version de l'algorithme. Les mots de passe du script SQL ont été hachés par une bibliothèque qui écrit des hachages commençant par [$2b$10$], alors que [BCryptPasswordEncoder] écrit par défaut des [$2a$]. Les deux versions sont vérifiées sans difficulté ; imposer la version [$2B] (une constante de l'énumération [BCryptVersion] : le caractère [$] est permis dans un identifiant [Java]) donne simplement aux comptes créés par l'application des hachages de même forme que ceux du script.
Le second extrait est la chaîne des filtres elle-même, avec ses deux réponses aux échecs.
| @Bean
public SecurityFilterChain filtresSecurite(HttpSecurity http, Flash flash) throws Exception {
AuthenticationEntryPoint versLogin = versLogin(flash);
AccessDeniedHandler accesRefuse = (request, response, exception) ->
response.sendError(HttpServletResponse.SC_FORBIDDEN);
http
// 1. les règles d'accès aux URL, examinées dans l'ordre. Les pages
// publiques : connexion, création de compte, choix de la langue,
// fichiers statiques, page d'erreur. Toutes les autres exigent un
// utilisateur authentifié ; les rôles sont vérifiés par les
// @PreAuthorize des contrôleurs.
.authorizeHttpRequests(acces -> acces
.requestMatchers("/", "/login", "/inscription", "/inscription/**", "/langue/**",
"/css/**", "/js/**", "/lib/**", "/error")
.permitAll()
.anyRequest().authenticated())
// 2. l'authentification par jeton JWT, lu dans le cookie
.oauth2ResourceServer(jwt -> jwt
.bearerTokenResolver(AuthService::jetonDe)
.authenticationEntryPoint(versLogin)
.accessDeniedHandler(accesRefuse)
.jwt(config -> config.jwtAuthenticationConverter(convertisseur())))
// 3. les deux cas d'échec
.exceptionHandling(erreurs -> erreurs
.authenticationEntryPoint(versLogin)
.accessDeniedHandler(accesRefuse))
// 4. la déconnexion (POST /logout) : effacer le cookie du jeton,
// puis aller à la page de connexion. POST seulement : un simple
// lien (GET), placé sur un autre site, ne peut pas déconnecter
// l'utilisateur à son insu
.logout(sortie -> sortie
.logoutRequestMatcher(PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/logout"))
.deleteCookies(AuthService.COOKIE_JWT)
.logoutSuccessUrl("/login"))
// 5. le serveur est sans état : jamais de session HTTP
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 6. pas de jeton anti-CSRF : les cookies SameSite=Lax protègent
// déjà les requêtes POST venues d'autres sites (cf. AuthService)
.csrf(csrf -> csrf.disable());
return http.build();
}
// -----------------------------------------------------------------------
// pas d'utilisateur authentifié -> page de connexion. Si un cookie était
// présent, c'est que son jeton n'est plus valide : la session a expiré.
// On efface alors le cookie, et on prévient l'utilisateur par un message
// flash (déposé hors de Spring MVC, d'où le bean Flash).
// -----------------------------------------------------------------------
private static AuthenticationEntryPoint versLogin(Flash flash) {
return (request, response, exception) -> {
if (AuthService.jetonDe(request) != null) {
AuthService.effacerJeton(response);
flash.deposer(request, response, MessageFlash.erreur("ERRORS.SESSION_EXPIRED"), "/login");
}
response.sendRedirect("/login");
};
}
// -----------------------------------------------------------------------
// du jeton à l'utilisateur authentifié : le claim [nom] est son nom
// (Authentication.getName()), le claim [role] son rôle. Spring Security
// note les rôles avec le préfixe ROLE_ : le claim role=ADMIN devient
// l'autorisation ROLE_ADMIN, que vérifie hasRole('ADMIN').
// -----------------------------------------------------------------------
private static JwtAuthenticationConverter convertisseur() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName(UtilisateurConnecte.ROLE);
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter convertisseur = new JwtAuthenticationConverter();
convertisseur.setJwtGrantedAuthoritiesConverter(roles);
convertisseur.setPrincipalClaimName(UtilisateurConnecte.NOM);
return convertisseur;
}
}
|
Commentons ce code :
- ligne 2 : le bean [SecurityFilterChain] décrit la chaîne des filtres. [HttpSecurity] est un constructeur d'objets (un « builder ») fourni par [Spring Security] : chaque méthode appelée sur lui ([authorizeHttpRequests], [oauth2ResourceServer], [logout]...) règle un aspect de la sécurité, et [build()] (ligne 40) fabrique la chaîne. Chacune reçoit une lambda (cf. exemple 25) qui configure l'aspect en question. Le paramètre [Flash flash] est un bean de l'application (dossier [web]), qui sert à déposer un message flash hors de [Spring MVC] ;
- lignes 4-5 : la réponse à un rôle insuffisant : [sendError(403)]. La lambda implémente l'interface [AccessDeniedHandler], qui n'a qu'une méthode (une interface fonctionnelle). [sendError] ne produit pas lui-même de page : le serveur [Tomcat] transmet la requête à l'adresse [/error], où [ErreurController] (cf. « La couche web : les contrôleurs ») fabrique la page d'erreur 403, comme dans l'exemple 26 ;
- lignes 12-16 : les règles d'accès aux URL, examinées dans l'ordre : la première qui correspond à l'URL s'applique. Sont publiques la racine, la connexion, la création de compte, le choix de la langue, les fichiers statiques et la page d'erreur ([/**] désigne tout ce qui est sous un chemin). Toute autre URL exige un utilisateur authentifié. Les rôles ne sont pas vérifiés ici, mais sur chaque action, par [@PreAuthorize] : la règle est ainsi écrite à côté du code qu'elle protège. [/error] doit être public : sans cela, la page d'erreur d'un visiteur non connecté serait elle-même refusée ;
- lignes 18-22 : l'authentification par jeton JWT, confiée au module « serveur de ressources » ([OAuth2] « resource server ») de [Spring Security], comme dans l'exemple 25. Par défaut, ce module cherche le jeton dans l'entête [Authorization: Bearer ...] ; la ligne 19 lui donne un autre [BearerTokenResolver], qui le lit dans le cookie [rdvmedecins_jwt]. [AuthService::jetonDe] est une référence de méthode : l'écriture abrégée de la lambda [request -> AuthService.jetonDe(request)]. La méthode statique [jetonDe] (fichier [auth/AuthService.java]) a en effet exactement la forme demandée par l'interface fonctionnelle [BearerTokenResolver] : elle reçoit la requête et rend le jeton, ou [null]. Ligne 22 : le convertisseur qui fabrique l'utilisateur authentifié à partir du jeton vérifié (lignes 65-73) ;
- lignes 20 et 25 : le même point d'entrée [versLogin] est donné deux fois, car il y a deux façons de ne pas être authentifié. Un jeton présent mais invalide (expiré, falsifié) est refusé par le filtre du serveur de ressources, qui appelle son propre point d'entrée (ligne 20). Aucun jeton : le filtre laisse passer la requête, anonyme, et c'est le filtre d'autorisation qui la refuse, en appelant le point d'entrée de [exceptionHandling] (ligne 25). De même, le gestionnaire du refus pour rôle insuffisant est donné aux deux endroits (lignes 21 et 26). C'est celui de [exceptionHandling] (ligne 26) qui traite les refus de [@PreAuthorize] ; la ligne 21 est une précaution, qui garde les deux configurations symétriques. Sans aucun gestionnaire, le serveur de ressources répondrait 403 à sa façon, avec un entête [WWW-Authenticate] destiné à un client [JavaScript], et sans page d'erreur ;
- lignes 31-34 : la déconnexion est traitée par [Spring Security] lui-même : aucun contrôleur n'a d'action [/logout]. [deleteCookies] efface le cookie du jeton, puis le navigateur est redirigé vers [/login]. La ligne 32 n'accepte que la méthode POST : quand la protection CSRF est désactivée (ligne 39), [Spring Security] accepte sinon [/logout] avec n'importe quelle méthode, GET compris, et une simple image [<img src="http://.../logout">] placée sur un autre site suffirait à déconnecter l'utilisateur. [PathPatternRequestMatcher] décrit une requête par sa méthode et son chemin ;
- ligne 36 : [STATELESS] : [Spring Security] ne crée jamais de session HTTP (le cookie [JSESSIONID] des exemples 20 et 23) et ne s'en sert pas pour mémoriser l'utilisateur : celui-ci est reconnu à chaque requête, à partir de son jeton. Le serveur ne garde rien en mémoire entre deux requêtes ;
- ligne 39 : [Spring Security] protège par défaut les formulaires POST contre l'attaque CSRF (une page piégée, sur un autre site, qui envoie un formulaire vers notre site) en exigeant dans chaque formulaire un champ caché contenant un jeton aléatoire. On s'en passe ici, comme dans l'exemple 25 : les cookies de l'application sont [SameSite=Lax], et le navigateur ne les joint pas à un POST venu d'un autre site ; la requête piégée arriverait donc sans jeton JWT, anonyme, et serait refusée ;
- lignes 49-57 : le point d'entrée, c'est-à-dire la réponse à une requête non authentifiée : une redirection vers la page de connexion (cf. exemple 25). Nouveauté (lignes 51-54) : si la requête portait un cookie de jeton, c'est que l'utilisateur s'était connecté, mais que son jeton n'est plus valide (expiré ou falsifié). On efface ce cookie devenu inutile, et on dépose un message flash « session expirée », que la page de connexion affichera au lieu de laisser l'utilisateur se demander pourquoi il se retrouve là. Le message est une clé de traduction (cf. exemple 22). On est ici dans un filtre de [Spring Security], avant [Spring MVC] : l'objet [RedirectAttributes] de l'exemple 20 n'existe pas encore, d'où le bean [Flash] (commenté avec le dossier [web]) ;
- lignes 65-73 : le convertisseur de l'exemple 26 : du jeton vérifié, il fait l'objet [Authentication] qui représente l'utilisateur de la requête. Le claim [role] fournit les autorisations, préfixées par [ROLE_] : le claim [role=ADMIN] devient l'autorisation [ROLE_ADMIN], celle que vérifie [hasRole('ADMIN')]. Le claim [nom] devient le nom de l'utilisateur ([Authentication.getName()]). Les noms des claims sont les constantes du record [UtilisateurConnecte] (fichier suivant).
C'est l'équivalent, en [Spring], de la configuration du gestionnaire [JwtBearer] d'[ASP.NET Core] et de ses « événements » : le [BearerTokenResolver] joue le rôle de l'événement [OnMessageReceived], l'[AuthenticationEntryPoint] celui de [OnChallenge].
Observons ces réponses avec [curl], sur des requêtes qui ne demandent pas de se connecter. Une page protégée, demandée sans cookie, renvoie vers la page de connexion :
| curl -i http://localhost:8081/agenda
|
| HTTP/1.1 302
Location: http://localhost:8081/login
Content-Length: 0
|
Pour la suite, il nous faut un jeton. Celui-ci est celui du patient [martin] ; il a été fabriqué pour l'occasion avec la clé secrète de [application.properties], par un petit programme qui fait le calcul HMAC-SHA256. C'est dire l'importance de garder cette clé secrète : qui la connaît peut fabriquer le jeton de n'importe qui. Le lecteur, lui, n'a pas besoin de ce calcul : il se connecte avec son navigateur, puis recopie la valeur du cookie [rdvmedecins_jwt] depuis les outils de développement (F12). En production, on la remplace par une longue chaîne aléatoire (cf. « La configuration du projet »). Un jeton est formé de trois parties codées en base64, séparées par des points : l'entête, le contenu, la signature.
| eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1IiwibG9naW4iOiJtYXJ0aW4iLCJub20iOiJKZWFuIE1BUlRJTiIsInJvbGUiOiJVU0VSIiwiZXhwIjoxNzkwMzYyMDM2LCJpYXQiOjE3OTAzNTQ4MzZ9.dZG-bMFddIzcFOuYUCOuoahH5iOEJ8_yUrrL14KVv-k
|
Une fois décodées, les deux premières parties se lisent en clair : le jeton est signé, pas chiffré.
| {"alg":"HS256"}
{"sub":"5","login":"martin","nom":"Jean MARTIN","role":"USER","exp":1790362036,"iat":1790354836}
|
Avec ce jeton, la racine du site renvoie le patient vers sa page d'accueil, « Mon espace » :
| curl -i -H "Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1Ii...Kv-k" http://localhost:8081/
|
| HTTP/1.1 302
Location: http://localhost:8081/mon-espace
Content-Length: 0
|
(le jeton est abrégé dans la commande). La gestion des médecins, réservée à l'ADMIN, est refusée : [@PreAuthorize] refuse l'accès, le gestionnaire [accesRefuse] répond [sendError(403)], et la page d'erreur est fabriquée par [ErreurController] :
| curl -i -H "Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1Ii...Kv-k" http://localhost:8081/medecins
|
| HTTP/1.1 403
Content-Type: text/html;charset=UTF-8
<!doctype html>
...
<h1 class="display-4 text-danger">403</h1>
<p class="lead">Votre rôle ne vous permet pas d'accéder à cette page.</p>
...
|
Le patient essaie maintenant de se faire passer pour l'ADMIN : il remplace, dans le contenu du jeton, ["role":"USER"] par ["role":"ADMIN"], et garde la signature. Le contenu modifié ne correspond plus à la signature : le jeton est refusé, et traité comme une session expirée :
| curl -i -H "Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1IiwibG9naW4iOiJtYXJ0aW4iLCJub20iOiJKZWFuIE1BUlRJTiIsInJvbGUiOiJBRE1JTiIsImV4cCI6MTc5MDM2MjAzNiwiaWF0IjoxNzkwMzU0ODM2fQ.dZG-bMFddIzcFOuYUCOuoahH5iOEJ8_yUrrL14KVv-k" http://localhost:8081/medecins
|
| HTTP/1.1 302
Set-Cookie: rdvmedecins_jwt=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Set-Cookie: rdvmedecins_flash=eyJjaGVtaW4iOiIvbG9naW4iLCJleHBpcmUiOjE3OTAzNTUxMjI2ODIsImZsYXNoIjp7InR5cGUiOiJEQU5HRVIiLCJjbGUiOiJFUlJPUlMuU0VTU0lPTl9FWFBJUkVEIiwicGFyYW1ldHJlcyI6e319fQ; Path=/; HttpOnly; SameSite=Lax
Location: http://localhost:8081/login
Content-Length: 0
|
Le point d'entrée [versLogin] a fait les trois choses prévues : le cookie du jeton est effacé (le premier entête [Set-Cookie] le redépose vide, avec une durée de vie nulle), un message flash est déposé dans le cookie [rdvmedecins_flash] (second entête, commenté avec le fichier [web/CookieFlashMapManager.java]), et le navigateur est renvoyé vers [/login], où le message s'affiche en bas de la page :

Le même traitement s'applique à un jeton expiré, et même sur une page publique comme [/login] : le filtre du serveur de ressources vérifie tout jeton présenté, avant que les règles d'accès ne soient examinées.
Reste la déconnexion. En POST, elle efface le cookie du jeton et renvoie vers la page de connexion :
| curl -i -X POST http://localhost:8081/logout
|
| HTTP/1.1 302
Set-Cookie: rdvmedecins_jwt=; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/
Location: http://localhost:8081/login
Content-Length: 0
|
En GET, l'URL [/logout] n'est qu'une page protégée comme une autre, inconnue de surcroît : sans jeton, la requête est renvoyée vers la page de connexion par le point d'entrée ; avec un jeton valide, elle obtiendrait une page 404. Dans aucun cas elle ne déconnecte l'utilisateur :
| curl -i http://localhost:8081/logout
|
| HTTP/1.1 302
Location: http://localhost:8081/login
Content-Length: 0
|
11.13.2. auth/UtilisateurConnecte.java
| // =========================================================================
// auth/UtilisateurConnecte.java
// -------------------------------------------------------------------------
// Ce que le jeton JWT contient, et comment le relire.
//
// Dans Spring Security, l'utilisateur de la requête est un objet
// Authentication, rangé dans le SecurityContext de la requête. Avec
// l'authentification par jeton JWT (cf. config/SecuriteConfig.java), c'est un
// JwtAuthenticationToken, qui contient le jeton vérifié : un objet Jwt, dont
// les "claims" (revendications) sont les champs du jeton. Un contrôleur
// l'obtient par un paramètre @AuthenticationPrincipal Jwt jeton.
//
// Les claims de nos jetons (cf. AuthService.creerJeton) :
// sub : l'id du compte utilisateur (convention JWT : "subject")
// login : le login
// nom : le nom affiché ("Marie PELISSIER"), pour la barre de navigation
// role : ADMIN, DOCTOR ou USER
// =========================================================================
package rdvmedecins.auth;
import org.springframework.security.oauth2.jwt.Jwt;
import rdvmedecins.entities.Role;
// l'utilisateur connecté, sous une forme typée
public record UtilisateurConnecte(int userId, String login, String nom, Role role) {
// les noms des claims
public static final String SUB = "sub";
public static final String LOGIN = "login";
public static final String NOM = "nom";
public static final String ROLE = "role";
// l'utilisateur décrit par un jeton déjà vérifié ; null si le jeton est
// absent (requête non authentifiée) ou mal formé
public static UtilisateurConnecte de(Jwt jeton) {
if (jeton == null) {
return null;
}
try {
return new UtilisateurConnecte(
Integer.parseInt(jeton.getSubject()),
jeton.getClaimAsString(LOGIN),
jeton.getClaimAsString(NOM),
Role.valueOf(jeton.getClaimAsString(ROLE)));
} catch (RuntimeException e) {
// NumberFormatException, IllegalArgumentException, NullPointerException
return null;
}
}
public boolean estAdmin() {
return role == Role.ADMIN;
}
public boolean estPatient() {
return role == Role.USER;
}
}
|
Commentons ce code :
- lignes 6-11 : dans [Spring Security], l'utilisateur de la requête est un objet [Authentication], rangé dans le [SecurityContext] de la requête. Avec les jetons JWT, c'est un [JwtAuthenticationToken], qui contient le jeton vérifié, un objet [Jwt]. Une action le reçoit par un paramètre annoté [@AuthenticationPrincipal Jwt jeton] (cf. exemple 25) ;
- ligne 26 : l'utilisateur connecté sous une forme typée : un identifiant entier, un rôle de type [Role] (l'énumération [ADMIN], [DOCTOR], [USER] du dossier [entities]), au lieu d'un jeton dont les claims se lisent par leur nom. C'est un record (cf. « Le domaine : l'agenda ») : [Java] écrit pour nous le constructeur et les accesseurs [userId()], [login()], [nom()], [role()] ;
- lignes 29-32 : les noms des claims du jeton, réunis dans des constantes : une faute de frappe dans [UtilisateurConnecte.NOM] est signalée par le compilateur, alors qu'une faute dans la chaîne ["nom"] ne serait découverte qu'à l'exécution. Un record peut contenir des champs statiques : ils appartiennent à la classe, et non à chaque objet ;
- lignes 36-50 : la méthode statique [de] fabrique l'utilisateur à partir d'un jeton déjà vérifié (sa signature est bonne), ou rend [null]. [getSubject()] rend le claim [sub], l'identifiant du compte, écrit sous forme de texte ; [Integer.parseInt] le convertit en entier. [Role.valueOf] rend la constante de l'énumération qui porte le nom donné. Ces deux méthodes lèvent une exception si le texte ne convient pas (ou s'il est [null]) : un jeton bien signé mais incomplet donne donc [null], et non une erreur 500 ;
- lignes 52-58 : deux méthodes pour les vues et les contrôleurs, qui évitent de comparer des rôles : [estAdmin()] et [estPatient()]. Une vue [Thymeleaf] écrit simplement [${utilisateur.estAdmin()}].
11.13.3. auth/AuthService.java
Le fichier est présenté en deux extraits.
| // =========================================================================
// auth/AuthService.java
// -------------------------------------------------------------------------
// La logique d'authentification :
// - validerUtilisateur() : vérifie un couple login / mot de passe ;
// - deposerJeton() : fabrique le jeton JWT d'un utilisateur validé
// et le range dans un cookie ;
// - effacerJeton() : supprime ce cookie ;
// - utilisateur() : l'utilisateur connecté de la requête en cours.
//
// Où le jeton JWT voyage-t-il ?
// Avec un client JavaScript, c'était le code du client qui gardait le jeton
// et l'ajoutait lui-même à chaque requête (entête "Authorization: Bearer
// ..."). Un navigateur qui affiche des pages HTML ordinaires ne sait pas
// faire cela : pour qu'il présente le jeton tout seul à chaque requête, il
// faut le ranger dans un COOKIE. Les options de ce cookie comptent pour la
// sécurité :
// - HttpOnly : le JavaScript de la page ne peut pas lire le cookie (un
// script injecté ne pourrait donc pas voler le jeton) ;
// - SameSite=Lax : le navigateur n'envoie PAS le cookie avec un POST venant
// d'un autre site. Comme toutes les actions qui modifient des données sont
// des POST, une page piégée sur un autre site ne peut pas réserver ou
// supprimer quoi que ce soit en se faisant passer pour l'utilisateur
// (attaque dite CSRF) ;
// - Secure : en production (HTTPS), le cookie ne circule jamais en clair ;
// - Max-Age : le cookie disparaît en même temps que le jeton expire.
// =========================================================================
package rdvmedecins.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.config.AuthProprietes;
import rdvmedecins.entities.User;
import rdvmedecins.repositories.UserRepository;
@Service
public class AuthService {
// le nom du cookie qui transporte le jeton
public static final String COOKIE_JWT = "rdvmedecins_jwt";
private final UserRepository userRepository;
private final PasswordEncoder hachage;
private final JwtEncoder encodeur;
private final JwtDecoder decodeur;
private final AuthProprietes auth;
public AuthService(UserRepository userRepository, PasswordEncoder hachage, JwtEncoder encodeur,
JwtDecoder decodeur, AuthProprietes auth) {
this.userRepository = userRepository;
this.hachage = hachage;
this.encodeur = encodeur;
this.decodeur = decodeur;
this.auth = auth;
}
// Optional.empty() si le login n'existe pas ou si le mot de passe est faux.
// matches hache le mot de passe saisi avec le "sel" contenu dans le
// hachage enregistré, puis compare les deux résultats.
public Optional<User> validerUtilisateur(String login, String password) {
return userRepository.findByLogin(login).filter(user -> hachage.matches(password, user.getPassword()));
}
|
Commentons ce code :
- lignes 11-26 : le commentaire explique pourquoi le jeton voyage dans un cookie, et le rôle de chacune de ses options (cf. exemple 25). Un navigateur qui suit des liens et envoie des formulaires ne sait pas ajouter un entête [Authorization] à ses requêtes ; il renvoie en revanche, tout seul, les cookies qu'il a reçus ;
- ligne 57 : [@Service] fait de la classe un bean singleton (cf. exemple 06), comme [@Component] : les deux annotations sont équivalentes, [@Service] indiquant seulement qu'il s'agit d'un service ;
- ligne 61 : le nom du cookie qui transporte le jeton ;
- lignes 63-76 : le service reçoit par son constructeur le DAO des comptes utilisateurs ([UserRepository], cf. « La couche DAO »), l'encodeur [bcrypt], l'encodeur et le décodeur de jetons (les beans de [SecuriteConfig]), et les propriétés de l'authentification. Les champs sont [final] : ils ne changent plus après la construction ;
- lignes 81-83 : le compte, si le login et le mot de passe sont corrects (cf. exemple 25). [findByLogin] rend un [Optional<User>], une boîte qui contient un compte ou qui est vide (cf. « La couche DAO ») ; [filter] garde le compte seulement si la condition est vraie, et rend sinon une boîte vide. La condition, [hachage.matches(...)], hache le mot de passe saisi avec le « sel » contenu dans le hachage enregistré, et compare les deux résultats : le mot de passe lui-même n'est jamais stocké.
| // -----------------------------------------------------------------------
// fabrique le jeton JWT de [user] et le dépose dans un cookie.
// Le jeton est SIGNÉ (HMAC-SHA256) avec la clé secrète : le navigateur
// peut le lire (il n'est pas chiffré), mais pas le modifier - toute
// modification rendrait la signature invalide.
// -----------------------------------------------------------------------
public void deposerJeton(HttpServletResponse response, User user) {
Instant maintenant = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(String.valueOf(user.getId()))
.claim(UtilisateurConnecte.LOGIN, user.getLogin())
.claim(UtilisateurConnecte.NOM, user.getNom())
.claim(UtilisateurConnecte.ROLE, user.getRole().name())
.issuedAt(maintenant)
.expiresAt(maintenant.plusSeconds(auth.jwtExpiresInSeconds()))
.build();
String jeton = encodeur.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims))
.getTokenValue();
ResponseCookie cookie = ResponseCookie.from(COOKIE_JWT, jeton)
.httpOnly(true)
.sameSite("Lax")
.secure(auth.cookieSecure())
.maxAge(Duration.ofSeconds(auth.jwtExpiresInSeconds()))
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
// efface le cookie : on le redépose, vide, avec une durée de vie nulle
public static void effacerJeton(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, ResponseCookie.from(COOKIE_JWT, "").path("/").maxAge(0).build().toString());
}
// le jeton présent dans la requête, qu'il soit valide ou non
public static String jetonDe(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, COOKIE_JWT);
return cookie == null ? null : cookie.getValue();
}
// -----------------------------------------------------------------------
// l'utilisateur connecté, ou null.
// Normalement, Spring Security l'a déjà établi (SecurityContextHolder).
// Mais une page d'erreur est fabriquée par une SECONDE requête interne,
// envoyée par le serveur à l'adresse /error, qui ne repasse pas par
// l'authentification : on relit alors nous-mêmes le jeton du cookie, pour
// que la barre de navigation de la page d'erreur montre l'utilisateur.
// -----------------------------------------------------------------------
public UtilisateurConnecte utilisateur(HttpServletRequest request) {
Authentication authentification = SecurityContextHolder.getContext().getAuthentication();
if (authentification != null && authentification.getPrincipal() instanceof Jwt jeton) {
return UtilisateurConnecte.de(jeton);
}
String jeton = jetonDe(request);
if (jeton == null) {
return null;
}
try {
return UtilisateurConnecte.de(decodeur.decode(jeton));
} catch (JwtException e) {
return null; // jeton expiré ou falsifié
}
}
}
|
Commentons ce code :
- lignes 8-19 : le jeton, fabriqué comme dans l'exemple 25. Il contient l'identifiant du compte (claim [sub]), le login, le nom affiché dans la barre de navigation et le rôle, la date de fabrication ([iat], « issued at ») et la date d'expiration ([exp]), deux heures plus tard par défaut. [JwtClaimsSet.builder()] rend un constructeur d'objets : on appelle une méthode par claim, puis [build()]. Ligne 18 : l'entête du jeton annonce l'algorithme HS256 (HMAC-SHA256), et l'encodeur signe l'ensemble avec la clé secrète ;
- lignes 20-27 : le cookie, avec les options de l'exemple 25 ([HttpOnly], [SameSite=Lax]) et deux de plus : [Secure], activé en production (site servi en [HTTPS]), interdit au navigateur d'envoyer le cookie sur une connexion non chiffrée ; [Path=/] le fait accompagner toutes les requêtes du site. [Max-Age] fait disparaître le cookie en même temps que le jeton expire. [ResponseCookie] fabrique le texte de l'entête [Set-Cookie], ajouté à la réponse par la ligne 27 ;
- lignes 31-33 : effacer un cookie, c'est le redéposer, vide, avec une durée de vie nulle ; le navigateur le supprime aussitôt. Pour qu'il reconnaisse le cookie à effacer, le chemin ([Path]) doit être celui du dépôt. La méthode est statique : elle ne dépend d'aucun champ de l'objet. Elle sert au point d'entrée de [SecuriteConfig] et au gestionnaire d'exceptions [HtmlExceptionHandler] (dossier [web]), qui n'ont pas besoin d'une instance de [AuthService] ;
- lignes 36-39 : le jeton présent dans la requête, sans le vérifier. [WebUtils.getCookie] cherche un cookie par son nom parmi ceux de la requête. C'est la méthode que [SecuriteConfig] donne comme [BearerTokenResolver] ;
- lignes 49-63 : l'utilisateur connecté, ou [null]. Dans le cas ordinaire (lignes 50-53), [Spring Security] l'a déjà établi : [SecurityContextHolder.getContext().getAuthentication()] rend l'objet [Authentication] de la requête en cours, dont le « principal » est le jeton vérifié. [instanceof Jwt jeton] teste le type de l'objet et, si le test réussit, le range dans la variable [jeton], déjà convertie en [Jwt]. Le second cas (lignes 54-62) est celui de la page d'erreur : quand une requête se termine par une erreur 403 ou 404, le serveur fabrique la page par une seconde requête, interne, vers [/error]. Le filtre du serveur de ressources ne traite pas cette requête interne : l'utilisateur n'y est pas authentifié. Pour que la barre de navigation de la page d'erreur affiche quand même l'utilisateur, on vérifie nous-mêmes le jeton du cookie, avec le décodeur de [SecuriteConfig] ; un jeton expiré ou falsifié lève une [JwtException], et l'on rend [null].
La méthode [utilisateur] est appelée pour chaque page par [web/ModeleCommun.java], qui place l'utilisateur dans le modèle de toutes les vues.
11.13.4. auth/SessionInvalideException.java
| // =========================================================================
// auth/SessionInvalideException.java
// -------------------------------------------------------------------------
// Le jeton est valide, mais le compte qu'il désigne n'existe plus : il a été
// supprimé depuis la connexion (par le patient lui-même dans un autre
// onglet, ou par l'ADMIN). On traite ce cas comme une session expirée :
// web/HtmlExceptionHandler efface le cookie et renvoie vers la page de
// connexion.
// =========================================================================
package rdvmedecins.auth;
public class SessionInvalideException extends RuntimeException {
public SessionInvalideException() {
super("le compte de l'utilisateur connecté n'existe plus");
}
}
|
- lignes 12-16 : un jeton peut être valide (bien signé, non expiré) alors que le compte qu'il désigne a été supprimé depuis la connexion. Les pages du patient le découvrent en cherchant le client correspondant au compte (méthode [patientConnecte] du fichier [web/Outils.java]) et lèvent alors cette exception ; le gestionnaire d'exceptions [HtmlExceptionHandler] (cf. exemple 24) l'intercepte, efface le cookie et renvoie vers la page de connexion, avec le message « session expirée ». L'exception dérive de [RuntimeException] : c'est une exception non vérifiée, que les méthodes n'ont pas à déclarer par une clause [throws]. Le constructeur transmet son message au constructeur de la classe mère par [super(...)].
11.13.5. auth/Captcha.java
| // =========================================================================
// auth/Captcha.java
// -------------------------------------------------------------------------
// Un captcha : id, à placer dans le champ caché "captchaId" du formulaire ;
// svg, l'image, à insérer telle quelle dans la page.
// =========================================================================
package rdvmedecins.auth;
public record Captcha(String id, String svg) {
}
|
- ligne 9 : un captcha, tel que la page le reçoit : l'identifiant, à placer dans un champ caché du formulaire, et l'image [SVG], à insérer telle quelle dans la page. Un record suffit : l'objet n'est que lu. La réponse attendue n'y figure pas : elle ne quitte jamais le serveur.
11.13.6. auth/CaptchaService.java
| // =========================================================================
// auth/CaptchaService.java
// -------------------------------------------------------------------------
// Un CAPTCHA ("test pour distinguer un humain d'un robot") fabriqué par le
// serveur lui-même, sans service extérieur : quelques caractères déformés,
// brouillés par des traits, dans une image SVG (cf. DessinCaptcha.java). Un
// humain les lit sans peine ; un robot qui se contente d'envoyer des
// formulaires en boucle ne sait pas le faire.
//
// Fonctionnement :
// 1. generer() tire un texte au hasard, en fait une image, et range la
// bonne réponse EN MÉMOIRE DU SERVEUR, sous un identifiant aléatoire ;
// 2. la page affiche l'image et place l'identifiant dans un champ caché du
// formulaire (la réponse, elle, ne quitte jamais le serveur) ;
// 3. verifier(id, saisie) compare la saisie à la réponse attendue, puis
// OUBLIE ce captcha : chaque image ne sert qu'une fois, un robot ne peut
// pas rejouer une réponse déjà trouvée.
//
// Bean SINGLETON (une seule instance pour tout le serveur), utilisé par
// plusieurs requêtes EN PARALLÈLE : d'où le ConcurrentHashMap, un
// dictionnaire que plusieurs threads peuvent lire et modifier en même temps
// sans le corrompre.
//
// Limite assumée : la mémoire d'un seul processus. Avec plusieurs serveurs
// derrière un répartiteur de charge, il faudrait un stockage partagé.
// =========================================================================
package rdvmedecins.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class CaptchaService {
// durée de validité d'une image : on a 10 minutes pour remplir le formulaire
private static final Duration DUREE_VIE = Duration.ofMinutes(10);
// garde-fou mémoire : au-delà, on oublie tous les captchas en attente
private static final int MAX_CAPTCHAS = 10_000;
// la réponse attendue, et sa date d'expiration
private record Attendu(String texte, Instant expire) {
}
// id -> réponse attendue
private final Map<String, Attendu> enAttente = new ConcurrentHashMap<>();
public Captcha generer() {
purger();
String texte = DessinCaptcha.texteAleatoire(5); // 5 caractères
String id = UUID.randomUUID().toString(); // un identifiant aléatoire, impossible à deviner
enAttente.put(id, new Attendu(texte, Instant.now().plus(DUREE_VIE)));
return new Captcha(id, DessinCaptcha.svg(texte));
}
// vrai si [saisie] est la bonne réponse au captcha [id] (sans tenir compte
// des majuscules). Le captcha est consommé dans tous les cas.
// remove est ATOMIQUE : si deux requêtes présentent le même captcha au
// même instant, une seule le retire - l'autre échoue.
public boolean verifier(String id, String saisie) {
Attendu attendu = id == null ? null : enAttente.remove(id);
return attendu != null && saisie != null
&& attendu.expire().isAfter(Instant.now())
&& attendu.texte().equalsIgnoreCase(saisie.trim());
}
private void purger() {
Instant maintenant = Instant.now();
enAttente.entrySet().removeIf(e -> !e.getValue().expire().isAfter(maintenant));
if (enAttente.size() >= MAX_CAPTCHAS) {
enAttente.clear();
}
}
}
|
Le service est celui de l'exemple 27, à deux ajouts près : une durée de validité et un plafond. Commentons ce code :
- ligne 37 : un bean singleton : un seul service pour tout le serveur, donc une seule liste de captchas en attente, que se partagent toutes les requêtes ;
- lignes 41-44 : une image est valable dix minutes, et le nombre de captchas en attente est plafonné. Chaque affichage de la page de connexion ajoute un captcha en mémoire ; sans plafond, un robot qui demanderait cette page des millions de fois sans jamais répondre finirait par épuiser la mémoire du serveur. [10_000] : le caractère [_] sépare les chiffres par groupes, pour la lisibilité ;
- lignes 47-48 : la réponse attendue et sa date d'expiration. Le record est imbriqué et privé : il n'existe que pour ce service. [Instant] représente un instant précis, indépendant du fuseau horaire ;
- ligne 51 : identifiant → réponse attendue. Le service est un singleton, utilisé par des requêtes simultanées, chacune sur son propre fil d'exécution ([thread]) : d'où le [ConcurrentHashMap] (cf. exemple 27), un dictionnaire que plusieurs [threads] peuvent lire et modifier en même temps sans le corrompre. Le champ est déclaré du type de l'interface [Map] : le reste du code ne dépend pas de l'implémentation choisie ;
- lignes 53-59 : le ménage, puis un texte de cinq caractères tiré au hasard, mémorisé sous un identifiant aléatoire ([UUID.randomUUID()], 122 bits aléatoires, impossible à deviner), et dessiné par [DessinCaptcha] ;
- lignes 65-70 : la vérification, identique à celle de l'exemple 27 : le captcha est retiré du dictionnaire dans tous les cas ([remove] rend la valeur retirée, ou [null]), puis on vérifie qu'il existait, qu'il n'a pas expiré et que la saisie, débarrassée de ses espaces, est la bonne, sans tenir compte des majuscules ([equalsIgnoreCase]). [remove] est atomique : si deux requêtes présentent le même captcha au même instant, une seule le retire, l'autre reçoit [null]. Chaque image ne sert donc qu'une fois : un robot ne peut pas rejouer une réponse trouvée ;
- lignes 72-78 : le ménage. [removeIf] retire du dictionnaire les entrées pour lesquelles la lambda rend [true], ici les captchas expirés. Si le plafond est encore atteint (une attaque est en cours), tous les captchas en attente sont oubliés : les utilisateurs de bonne foi qui remplissaient à ce moment le formulaire devront recopier une nouvelle image. Le cas ne se produit qu'en cas d'attaque : cette solution simple suffit.
11.13.7. auth/DessinCaptcha.java
La classe qui dessine l'image est celle de l'exemple 27. Elle est présentée en deux extraits : la « police » de caractères, puis le dessin.
| // =========================================================================
// auth/DessinCaptcha.java
// -------------------------------------------------------------------------
// Le dessin de l'image SVG (150 x 50 pixels) d'un captcha, repris tel quel
// de l'exemple 27. Point important : chaque caractère est dessiné par des
// TRAITS (<path>), jamais écrit comme du texte (<text>) - sinon un robot
// n'aurait qu'à lire le code source de l'image.
// =========================================================================
package rdvmedecins.auth;
import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
final class DessinCaptcha {
private static final int LARGEUR = 150;
private static final int HAUTEUR = 50;
// SecureRandom : un générateur de nombres aléatoires "cryptographique",
// imprévisible (contrairement à Random, dont la suite pourrait être devinée)
private static final SecureRandom HASARD = new SecureRandom();
// La "police" : chaque caractère est une liste de traits, chaque trait une
// suite de points (x, y) dans une grille de 4 x 6 (y vers le bas).
// Caractères trop faciles à confondre entre eux : exclus (0 O, 1 I L).
private static final Map<Character, double[][]> POLICE = new LinkedHashMap<>();
static {
POLICE.put('A', new double[][] { { 0, 6, 2, 0, 4, 6 }, { 0.8, 3.8, 3.2, 3.8 } });
POLICE.put('B', new double[][] { { 0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3 }, { 0, 0, 3, 0, 4, 1, 4, 2, 3, 3 } });
POLICE.put('C', new double[][] { { 4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5 } });
POLICE.put('D', new double[][] { { 0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0 } });
POLICE.put('E', new double[][] { { 4, 0, 0, 0, 0, 6, 4, 6 }, { 0, 3, 3, 3 } });
POLICE.put('F', new double[][] { { 4, 0, 0, 0, 0, 6 }, { 0, 3, 3, 3 } });
POLICE.put('G', new double[][] { { 4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5 } });
POLICE.put('H', new double[][] { { 0, 0, 0, 6 }, { 4, 0, 4, 6 }, { 0, 3, 4, 3 } });
POLICE.put('J', new double[][] { { 4, 0, 4, 5, 3, 6, 1, 6, 0, 5 } });
POLICE.put('K', new double[][] { { 0, 0, 0, 6 }, { 4, 0, 0, 3.5 }, { 1.3, 2.6, 4, 6 } });
POLICE.put('M', new double[][] { { 0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6 } });
POLICE.put('N', new double[][] { { 0, 6, 0, 0, 4, 6, 4, 0 } });
POLICE.put('P', new double[][] { { 0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3 } });
POLICE.put('Q', new double[][] { { 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0 }, { 2.5, 4.5, 4, 6.3 } });
POLICE.put('R', new double[][] { { 0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3 }, { 2, 3, 4, 6 } });
POLICE.put('S', new double[][] { { 4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5 } });
POLICE.put('T', new double[][] { { 0, 0, 4, 0 }, { 2, 0, 2, 6 } });
POLICE.put('U', new double[][] { { 0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0 } });
POLICE.put('V', new double[][] { { 0, 0, 2, 6, 4, 0 } });
POLICE.put('W', new double[][] { { 0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0 } });
POLICE.put('X', new double[][] { { 0, 0, 4, 6 }, { 4, 0, 0, 6 } });
POLICE.put('Y', new double[][] { { 0, 0, 2, 3, 4, 0 }, { 2, 3, 2, 6 } });
POLICE.put('Z', new double[][] { { 0, 0, 4, 0, 0, 6, 4, 6 } });
POLICE.put('2', new double[][] { { 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6 } });
POLICE.put('3', new double[][] { { 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3 }, { 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5 } });
POLICE.put('4', new double[][] { { 3, 6, 3, 0, 0, 4, 4, 4 } });
POLICE.put('5', new double[][] { { 4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5 } });
POLICE.put('6', new double[][] { { 4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3 } });
POLICE.put('7', new double[][] { { 0, 0, 4, 0, 1.5, 6 } });
POLICE.put('8', new double[][] { { 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3 } });
POLICE.put('9', new double[][] { { 4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5 } });
}
private static final char[] ALPHABET = POLICE.keySet().stream()
.map(String::valueOf).reduce("", String::concat).toCharArray();
// quelques couleurs foncées, bien lisibles sur le fond clair
private static final String[] COULEURS = { "#1f4e79", "#7b2d26", "#2e6b30", "#5b2c83", "#8a5a00", "#23395d", "#9c2a5b" };
|
Commentons ce code :
- ligne 16 : la classe n'est pas [public] : sans modificateur, elle n'est visible que dans son paquetage ([rdvmedecins.auth]), où seul [CaptchaService] s'en sert. [final] interdit d'en dériver une autre classe ;
- ligne 23 : [SecureRandom] est un générateur de nombres aléatoires « cryptographique » : ses tirages sont imprévisibles, alors que ceux de [Random] pourraient être devinés à partir des précédents. Un seul générateur sert à tous les tirages ; il peut être utilisé par plusieurs [threads] à la fois ;
- lignes 28-62 : la « police » : pour chaque caractère, une liste de traits, chaque trait étant une suite de coordonnées (x, y) dans une grille de 4 × 6. [double[][]] est un tableau de tableaux, écrit ici en extension avec des accolades. Le dictionnaire est rempli par un bloc [static { ... }] (lignes 30-62), exécuté une fois, au chargement de la classe. Les caractères faciles à confondre (0 et O, 1, I et L) sont absents. Un [LinkedHashMap] conserve l'ordre d'insertion des clés ;
- lignes 64-65 : les caractères utilisables, c'est-à-dire les clés de la police, rassemblés en un tableau de [char]. Le flux (« stream », cf. « La couche métier ») des clés est transformé en chaînes d'un caractère ([String::valueOf]), qui sont mises bout à bout ([reduce("", String::concat)]), puis la chaîne obtenue est découpée en tableau ([toCharArray()]).
| private DessinCaptcha() {
}
static String texteAleatoire(int longueur) {
StringBuilder texte = new StringBuilder();
for (int i = 0; i < longueur; i++) {
texte.append(ALPHABET[HASARD.nextInt(ALPHABET.length)]);
}
return texte.toString();
}
private static double hasard(double min, double max) {
return min + (max - min) * HASARD.nextDouble();
}
private static String couleur() {
return COULEURS[HASARD.nextInt(COULEURS.length)];
}
static String svg(String texte) {
StringBuilder svg = new StringBuilder();
svg.append("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\">"
.formatted(LARGEUR, HAUTEUR, LARGEUR, HAUTEUR));
svg.append("<rect width=\"100%\" height=\"100%\" fill=\"#f5f7fa\"/>");
// les caractères : chacun agrandi, penché, décalé, et chaque point
// légèrement déplacé au hasard
double pas = (LARGEUR - 20) / (double) texte.length();
for (int i = 0; i < texte.length(); i++) {
double echelle = hasard(4.6, 5.6); // taille d'une unité de la grille, en pixels
double angle = hasard(-0.35, 0.35); // rotation, en radians (environ ±20°)
double cx = 10 + pas * (i + 0.5) + hasard(-3, 3); // centre du caractère
double cy = HAUTEUR / 2.0 + hasard(-4, 4);
StringBuilder d = new StringBuilder();
for (double[] trait : POLICE.get(texte.charAt(i))) {
for (int k = 0; k < trait.length; k += 2) {
// point de la grille, centré sur (2, 3), déformé, tourné, placé
double x = (trait[k] - 2 + hasard(-0.2, 0.2)) * echelle;
double y = (trait[k + 1] - 3 + hasard(-0.2, 0.2)) * echelle;
double px = cx + x * Math.cos(angle) - y * Math.sin(angle);
double py = cy + x * Math.sin(angle) + y * Math.cos(angle);
d.append(k == 0 ? 'M' : 'L').append(nombre(px)).append(' ').append(nombre(py)).append(' ');
}
}
svg.append("<path d=\"%s\" fill=\"none\" stroke=\"%s\" stroke-width=\"%s\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>"
.formatted(d.toString().trim(), couleur(), nombre(hasard(2.2, 3))));
}
// 3 traits de brouillage : des courbes qui traversent l'image
for (int n = 0; n < 3; n++) {
svg.append("<path d=\"M%s %s C%s %s %s %s %s %s\" fill=\"none\" stroke=\"%s\" stroke-width=\"1.3\"/>"
.formatted(nombre(hasard(0, 20)), nombre(hasard(5, 45)),
nombre(hasard(30, 60)), nombre(hasard(0, 50)), nombre(hasard(90, 120)), nombre(hasard(0, 50)),
nombre(hasard(130, 150)), nombre(hasard(5, 45)), couleur()));
}
svg.append("</svg>");
return svg.toString();
}
// un nombre avec un point décimal, quelle que soit la langue du serveur
// (en français, "%.1f" écrirait "12,5" - illisible pour SVG)
private static String nombre(double valeur) {
return String.format(Locale.ROOT, "%.1f", valeur);
}
}
|
Commentons ce code :
- lignes 1-2 : un constructeur privé : la classe ne contient que des méthodes statiques, on n'en crée jamais d'objet ;
- lignes 4-18 : les tirages au hasard : un texte de la longueur demandée, un nombre réel entre deux bornes, une couleur. [nextInt(n)] rend un entier entre 0 et n - 1, [nextDouble()] un réel entre 0 et 1 ;
- lignes 20-58 : l'image est un texte [SVG], assemblé dans un [StringBuilder] (une chaîne qu'on allonge sans la recopier à chaque ajout). La méthode [formatted] remplace les [%d] (un entier) et les [%s] (un texte) par ses arguments, dans l'ordre. Chaque caractère (lignes 29-47) devient un chemin [<path>] : chaque point de la grille est légèrement déplacé, agrandi, tourné et placé à son rang dans l'image, avec une couleur et une épaisseur de trait tirées au hasard. Les caractères sont des traits, jamais du texte : un robot ne peut pas les lire dans le code de l'image. Trois courbes de brouillage traversent ensuite l'image (lignes 50-55) ;
- lignes 62-64 : les nombres du [SVG] s'écrivent avec un point décimal. [Locale.ROOT] impose cette écriture quelle que soit la langue du serveur : avec une langue française, [%.1f] écrirait une virgule (cf. chapitre 5).
11.13.8. auth/LimiteurTentativesService.java
Le fichier est présenté en deux extraits.
| // =========================================================================
// auth/LimiteurTentativesService.java
// -------------------------------------------------------------------------
// Second rempart contre les robots (le premier est le captcha) : on COMPTE
// les tentatives, et au-delà d'un seuil on BLOQUE pendant un moment.
//
// - connexion : après loginMaxEchecs échecs (mauvais captcha ou mauvais
// mot de passe) pour un même login depuis une même adresse IP, ce couple
// est bloqué loginBlocageMinutes minutes. Une même adresse IP est aussi
// bloquée si elle cumule 5 fois plus d'échecs, tous logins confondus (un
// robot qui essaie des milliers de logins différents) ;
// - création de compte : au plus inscriptionMaxParHeure comptes créés par
// heure depuis une même adresse IP.
//
// Attention en salle de cours : tous les postes sortent souvent sur Internet
// par la MÊME adresse IP. C'est pourquoi le blocage de la connexion porte
// d'abord sur le couple (IP, login) - un étudiant qui se trompe de mot de
// passe ne bloque pas ses voisins - et pourquoi les seuils sont réglables
// dans application.properties (rdvmedecins.protection.*).
//
// Bean singleton, appelé par des requêtes parallèles : toutes les méthodes
// publiques sont "synchronized" - un seul thread à la fois lit ET modifie
// les compteurs.
// =========================================================================
package rdvmedecins.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import rdvmedecins.config.ProtectionProprietes;
@Service
public class LimiteurTentativesService {
private static class Compteur {
int nombre; // tentatives comptées dans la fenêtre en cours
Instant debut; // début de la fenêtre de comptage
Instant bloqueJusqua = Instant.MIN; // Instant.MIN si pas bloqué
}
private static final Duration UNE_HEURE = Duration.ofHours(1);
private final Map<String, Compteur> compteurs = new HashMap<>();
private final int maxEchecsLogin;
private final Duration dureeBlocageLogin;
private final int maxInscriptions;
public LimiteurTentativesService(ProtectionProprietes protection) {
this.maxEchecsLogin = protection.loginMaxEchecs();
this.dureeBlocageLogin = Duration.ofMinutes(protection.loginBlocageMinutes());
this.maxInscriptions = protection.inscriptionMaxParHeure();
}
// ---------------------------------------------------------------- connexion
// nombre de minutes de blocage restantes (0 = la tentative est autorisée)
public synchronized long minutesBlocageLogin(String ip, String login) {
return Math.max(minutesRestantes(cleLogin(ip, login)), minutesRestantes(cleIp(ip)));
}
public synchronized void echecLogin(String ip, String login) {
compter(cleLogin(ip, login), maxEchecsLogin, dureeBlocageLogin, dureeBlocageLogin);
compter(cleIp(ip), maxEchecsLogin * 5, dureeBlocageLogin, dureeBlocageLogin);
}
// une connexion réussie remet à zéro le compteur de CE login
public synchronized void reussiteLogin(String ip, String login) {
compteurs.remove(cleLogin(ip, login));
}
// ---------------------------------------------------------------- inscription
public synchronized long minutesBlocageInscription(String ip) {
return minutesRestantes("inscription|" + ip);
}
// fenêtre d'une heure ; une fois le quota atteint, blocage jusqu'à la fin de l'heure
public synchronized void inscriptionReussie(String ip) {
compter("inscription|" + ip, maxInscriptions, UNE_HEURE, UNE_HEURE);
}
|
Par rapport au [LimiteurService] de l'exemple 27, le limiteur protège deux opérations (la connexion et la création de compte), le blocage de la connexion porte sur le couple (adresse IP, login), et les seuils sont réglables dans [application.properties]. Commentons ce code :
- lignes 40-44 : un compteur : le nombre de tentatives, le début de la fenêtre de comptage, la fin du blocage ([Instant.MIN], le plus petit instant possible, s'il n'y a pas de blocage). C'est une classe imbriquée statique et privée : elle n'existe que pour ce service. C'est une classe ordinaire, et non un record : un compteur lu dans le dictionnaire est modifié sur place, ce qu'interdit un record ;
- ligne 48 : un [HashMap] ordinaire, et non un [ConcurrentHashMap] : compter une tentative, c'est lire un compteur, le modifier, peut-être le remplacer, et toute cette suite d'opérations doit s'exécuter sans qu'une autre requête s'intercale. C'est le rôle du mot-clé [synchronized] (cf. exemple 27), placé sur chaque méthode publique : un seul [thread] à la fois peut exécuter l'une des méthodes [synchronized] de l'objet ; les autres attendent leur tour ;
- lignes 54-58 : les seuils, lus une fois pour toutes dans le record [ProtectionProprietes], qui donne une forme typée aux propriétés [rdvmedecins.protection.*] : le nombre d'échecs de connexion tolérés (5 par défaut), la durée du blocage (15 minutes) et le nombre de comptes créés par heure (10) ;
- lignes 63-65 : le temps de blocage restant pour une tentative de connexion : le plus long des deux blocages possibles, celui du couple (adresse IP, login) et celui de l'adresse IP seule ;
- lignes 67-70 : un échec est compté deux fois. Pour le couple (adresse IP, login) : cinq échecs sur le même login bloquent ce login, depuis cette adresse seulement. Pour l'adresse IP seule, avec un seuil cinq fois plus haut : un robot qui essaie un grand nombre de logins différents est bloqué, lui aussi. En salle de cours, tous les postes sortent souvent sur Internet par la même adresse IP : un étudiant qui se trompe de mot de passe ne doit pas bloquer ses voisins, d'où le blocage par couple ;
- lignes 73-75 : une connexion réussie remet à zéro le compteur de ce login (pas celui de l'adresse IP) ;
- lignes 79-86 : pour la création de compte, ce sont les réussites que l'on compte : au plus dix comptes par heure depuis une même adresse IP. Le quota atteint, l'adresse est bloquée une heure.
| // ---------------------------------------------------------------- mécanique commune
// (les méthodes privées sont toujours appelées par une méthode synchronized)
private long minutesRestantes(String cle) {
Compteur c = compteurs.get(cle);
if (c == null) {
return 0;
}
Duration reste = Duration.between(Instant.now(), c.bloqueJusqua);
// arrondi à la minute SUPÉRIEURE : 30 s de blocage restantes -> "1 minute"
return reste.isNegative() || reste.isZero() ? 0 : (reste.toSeconds() + 59) / 60;
}
// compte une tentative ; si le seuil est atteint dans la fenêtre, bloque
private void compter(String cle, int max, Duration fenetre, Duration blocage) {
Instant maintenant = Instant.now();
purger(maintenant);
Compteur c = compteurs.get(cle);
if (c == null || Duration.between(c.debut, maintenant).compareTo(fenetre) > 0) {
c = new Compteur();
c.debut = maintenant;
compteurs.put(cle, c);
}
c.nombre++;
if (c.nombre >= max) {
c.bloqueJusqua = maintenant.plus(blocage);
c.nombre = 0; // après le blocage, on repart de zéro
c.debut = c.bloqueJusqua;
}
}
// on oublie les compteurs qui ne servent plus (ni bloqués, ni récents)
private void purger(Instant maintenant) {
if (compteurs.size() < 1000) {
return;
}
compteurs.values().removeIf(c -> c.bloqueJusqua.isBefore(maintenant)
&& Duration.between(c.debut, maintenant).compareTo(UNE_HEURE) > 0);
}
private static String cleLogin(String ip, String login) {
return "login|" + ip + "|" + login.trim().toLowerCase(Locale.ROOT);
}
private static String cleIp(String ip) {
return "ip|" + ip;
}
}
|
Commentons ce code :
- lignes 4-12 : les minutes de blocage restantes, arrondies au nombre entier supérieur : il reste « 1 minute », et non « 0 minute », quand il reste 20 secondes. [Duration.between] mesure l'écart entre deux instants. Ces méthodes privées ne sont pas [synchronized] : elles sont toujours appelées par une méthode publique qui détient déjà le verrou de l'objet ;
- lignes 15-30 : le comptage. Une clé inconnue, ou dont la fenêtre de comptage est dépassée, repart d'un compteur neuf. Quand le seuil est atteint, la clé est bloquée, et le comptage reprendra de zéro à la fin du blocage ;
- lignes 33-39 : le ménage des compteurs devenus inutiles (ni bloqués, ni récents), fait seulement quand le dictionnaire dépasse mille entrées. [values()] est une vue sur les valeurs du dictionnaire : [removeIf] y retire les compteurs, et les entrées correspondantes disparaissent du dictionnaire ;
- lignes 41-47 : la fabrication des clés du dictionnaire. Le login est débarrassé de ses espaces et mis en minuscules : [Admin] et [admin] partagent le même compteur, un robot ne peut pas contourner le blocage en changeant la casse. [Locale.ROOT] rend la conversion indépendante de la langue du serveur.
Observons le blocage. Un robot qui essaie le login [robot] sans savoir lire l'image échoue au captcha ; chaque échec est compté. À la sixième tentative, le couple (adresse IP, [robot]) est bloqué, et le captcha n'est même plus examiné. Voici le message affiché en bas de la page à chacune des six tentatives :
| curl -X POST -d "login=robot&password=x&captchaId=inconnu&captcha=ABCDE" http://localhost:8081/login
|
| <div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
<div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
<div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
<div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
<div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
<div>Trop de tentatives infructueuses. Réessayez dans 15 minute(s).</div>
|
Les autres logins restent utilisables depuis la même adresse.
11.13.9. auth/PasseInscriptionService.java
| // =========================================================================
// auth/PasseInscriptionService.java
// -------------------------------------------------------------------------
// Le "laissez-passer" de la création de compte.
//
// Pour ouvrir la page "Créer un compte client", il faut d'abord avoir
// recopié le captcha de la page d'authentification (cf.
// InscriptionController, POST /inscription/acces). Le serveur délivre alors
// un laissez-passer :
// - un identifiant aléatoire, rangé dans un cookie HttpOnly ;
// - valable 15 minutes (le temps de remplir le formulaire, même en s'y
// reprenant à plusieurs fois après des erreurs de saisie) ;
// - consommé dès qu'un compte est créé : UN captcha résolu = UN compte au
// plus. Un robot ne peut pas créer des comptes en série.
//
// Sans laissez-passer valide, GET et POST /inscription renvoient vers la
// page d'authentification : taper directement l'URL /inscription ne permet
// pas de contourner le captcha.
//
// Comme le captcha, les laissez-passer vivent dans la mémoire du processus.
// =========================================================================
package rdvmedecins.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
@Service
public class PasseInscriptionService {
public static final String COOKIE = "rdvmedecins_inscription";
private static final Duration DUREE_VIE = Duration.ofMinutes(15);
private static final int MAX_PASSES = 10_000;
// id -> date d'expiration
private final Map<String, Instant> passes = new ConcurrentHashMap<>();
// délivre un nouveau laissez-passer ; renvoie son identifiant
public String emettre() {
purger();
String id = UUID.randomUUID().toString();
passes.put(id, Instant.now().plus(DUREE_VIE));
return id;
}
// vrai si [id] est un laissez-passer existant et non expiré
public boolean estValide(String id) {
Instant expire = id == null ? null : passes.get(id);
return expire != null && expire.isAfter(Instant.now());
}
// le laissez-passer a servi : on l'oublie
public void consommer(String id) {
if (id != null) {
passes.remove(id);
}
}
// le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
public static ResponseCookie cookie(String id) {
return ResponseCookie.from(COOKIE, id).httpOnly(true).sameSite("Lax").maxAge(DUREE_VIE)
.path("/inscription").build();
}
// ... et il est effacé en le redéposant vide, avec une durée de vie nulle
public static ResponseCookie cookieEfface() {
return ResponseCookie.from(COOKIE, "").maxAge(0).path("/inscription").build();
}
private void purger() {
Instant maintenant = Instant.now();
passes.values().removeIf(expire -> !expire.isAfter(maintenant));
if (passes.size() >= MAX_PASSES) {
passes.clear();
}
}
}
|
La création d'un compte client est ouverte depuis la page de connexion, à condition d'avoir résolu son captcha. Le formulaire d'inscription est affiché et envoyé par des requêtes ultérieures : il faut qu'elles sachent qu'une requête précédente a résolu le captcha. C'est le rôle du laissez-passer, délivré et vérifié par [InscriptionController] (cf. « La couche web : les contrôleurs ») :
- ligne 36 : le nom du cookie qui transporte le laissez-passer ;
- lignes 38-42 : un laissez-passer est valable quinze minutes ; les laissez-passer en attente sont gardés en mémoire, comme les captchas, avec le même plafond ;
- lignes 45-50 : un laissez-passer est un identifiant aléatoire, mémorisé avec sa date d'expiration ; le contrôleur le dépose dans un cookie ;
- lignes 53-56 : le formulaire d'inscription n'est affiché, et accepté, qu'avec un laissez-passer valide. La vérification ne le consomme pas ([get], et non [remove]) : l'utilisateur peut renvoyer le formulaire plusieurs fois s'il a fait des erreurs de saisie ;
- lignes 59-63 : un compte créé consomme le laissez-passer : un captcha résolu donne droit à un compte, pas plus ;
- lignes 66-74 : le cookie et son effacement. [path("/inscription")] limite l'envoi du cookie aux URL qui commencent par [/inscription] : les autres requêtes ne le transportent pas inutilement. Le cookie effacé doit porter le même chemin ;
- lignes 76-82 : le ménage, identique à celui du captcha.
11.13.10. controllers/AuthController.java
Le contrôleur de la page de connexion est présenté en deux extraits.
| // =========================================================================
// controllers/AuthController.java
// -------------------------------------------------------------------------
// Les routes NON protégées de la connexion :
// GET / redirige vers la page d'accueil du rôle (ou vers /login)
// GET /login affiche le formulaire de connexion (avec son captcha)
// POST /login vérifie limite de tentatives, captcha, login / mot de passe
// (POST /logout, la déconnexion, est traitée par Spring Security lui-même,
// cf. config/SecuriteConfig.java)
//
// Protection contre les robots, dans cet ordre :
// 1. trop d'échecs récents pour ce login depuis cette adresse IP ?
// -> refus immédiat, sans même regarder le mot de passe ;
// 2. mauvaise réponse au captcha ? -> refus (compté comme un échec) ;
// 3. seulement alors, vérification du mot de passe.
// On ne vérifie JAMAIS le mot de passe d'une tentative refusée aux étapes
// 1 ou 2 : un robot ne peut donc rien apprendre en insistant.
// =========================================================================
package rdvmedecins.controllers;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.auth.AuthService;
import rdvmedecins.auth.CaptchaService;
import rdvmedecins.auth.LimiteurTentativesService;
import rdvmedecins.auth.UtilisateurConnecte;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.User;
import rdvmedecins.models.ModelesVues.LoginModele;
import rdvmedecins.models.ModelesVues.Message;
@Controller
public class AuthController {
private final AuthService authService;
private final CaptchaService captchaService;
private final LimiteurTentativesService limiteur;
public AuthController(AuthService authService, CaptchaService captchaService, LimiteurTentativesService limiteur) {
this.authService = authService;
this.captchaService = captchaService;
this.limiteur = limiteur;
}
// la page d'accueil de chaque rôle, juste après la connexion
public static String pageAccueil(Role role) {
return role == Role.USER ? "/mon-espace" : "/agenda";
}
// -----------------------------------------------------------------------
// GET /
// La route est publique (cf. SecuriteConfig) : Spring Security a pu
// établir l'utilisateur si le cookie contient un jeton valide, mais
// n'exige rien. AuthService.utilisateur() dit s'il y en a un (ou null).
// -----------------------------------------------------------------------
@GetMapping("/")
public String accueil(HttpServletRequest request) {
UtilisateurConnecte utilisateur = authService.utilisateur(request);
return "redirect:" + (utilisateur != null ? pageAccueil(utilisateur.role()) : "/login");
}
// -----------------------------------------------------------------------
// GET /login[?login=xxx] : le paramètre facultatif pré-remplit le champ
// login (utilisé juste après la création d'un compte, cf. InscriptionController)
// -----------------------------------------------------------------------
@GetMapping("/login")
public String afficherLogin(@RequestParam(required = false) String login, HttpServletRequest request,
Model model) {
// déjà connecté (jeton encore valide) : directement à la page d'accueil
UtilisateurConnecte utilisateur = authService.utilisateur(request);
if (utilisateur != null) {
return "redirect:" + pageAccueil(utilisateur.role());
}
String saisie = login == null ? "" : login.substring(0, Math.min(login.length(), 30));
return formulaire(model, saisie, null);
}
// -----------------------------------------------------------------------
// POST /login
// Les paramètres de l'action sont remplis avec les champs du formulaire
// de même nom. required = false : un champ absent donne null (et non une
// erreur 400) - c'est le code qui décide quoi en faire.
// -----------------------------------------------------------------------
|
Commentons ce code :
- lignes 11-17 : l'ordre des vérifications de la connexion compte. Le mot de passe d'une tentative refusée à cause d'un blocage ou d'un mauvais captcha n'est jamais vérifié : un robot qui insiste n'apprend rien ;
- lignes 41-52 : [@Controller] fait de la classe un contrôleur dont les actions rendent des noms de vues (cf. exemple 13). Il reçoit par son constructeur les trois services dont il a besoin. Aucune annotation [@PreAuthorize] : ses pages sont publiques (cf. [SecuriteConfig]) ;
- lignes 55-57 : la page d'accueil dépend du rôle : « Mon espace » pour un patient, l'agenda pour les autres. La méthode est statique : elle ne dépend d'aucun champ de l'objet ;
- lignes 65-69 : la racine du site redirige (préfixe [redirect:], cf. exemple 04) vers la page d'accueil du rôle, ou vers la page de connexion. La route est publique, mais le filtre du serveur de ressources s'exécute pour toutes les requêtes : si le cookie contient un jeton valide, l'utilisateur est établi, même ici ; [authService.utilisateur] le rend, ou rend [null] ;
- lignes 75-85 : un utilisateur déjà connecté est envoyé à sa page d'accueil. Le paramètre facultatif [login], venu de l'URL ([@RequestParam(required = false)], cf. exemple 07), préremplit le champ après la création d'un compte. [substring] en garde les trente premiers caractères au plus : une donnée venue de la requête n'est jamais utilisée sans précaution.
| @PostMapping("/login")
public String seConnecter(
@RequestParam(required = false) String login,
@RequestParam(required = false) String password,
@RequestParam(required = false) String captchaId,
@RequestParam(required = false) String captcha,
HttpServletRequest request, HttpServletResponse response, Model model) {
login = login == null ? "" : login.trim();
password = password == null ? "" : password;
String ip = adresseIp(request);
// 1. trop d'échecs récents ?
long minutes = limiteur.minutesBlocageLogin(ip, login);
if (minutes > 0) {
return formulaire(model, login,
new Message("ERRORS.TOO_MANY_ATTEMPTS", Map.of("minutes", String.valueOf(minutes))));
}
// 2. le captcha
if (!captchaService.verifier(captchaId, captcha)) {
limiteur.echecLogin(ip, login);
return formulaire(model, login, Message.de("ERRORS.WRONG_CAPTCHA"));
}
// 3. le mot de passe
Optional<User> user = login.isEmpty() || password.isEmpty()
? Optional.empty()
: authService.validerUtilisateur(login, password);
if (user.isEmpty()) {
limiteur.echecLogin(ip, login);
return formulaire(model, login, Message.de("LOGIN.ERROR"));
}
limiteur.reussiteLogin(ip, login);
authService.deposerJeton(response, user.get());
return "redirect:" + pageAccueil(user.get().getRole());
}
// l'adresse IP du visiteur. Derrière un proxy inverse (nginx...), il
// faudrait activer server.forward-headers-strategy=native pour obtenir
// celle du visiteur et non celle du proxy.
public static String adresseIp(HttpServletRequest request) {
return request.getRemoteAddr();
}
// le formulaire, toujours avec un NOUVEAU captcha (chaque image ne sert
// qu'une fois) ; on réaffiche le login saisi, jamais le mot de passe
private String formulaire(Model model, String login, Message erreur) {
model.addAttribute("modele", new LoginModele("LOGIN.TITLE", erreur, login, captchaService.generer()));
return "login";
}
}
|
Commentons ce code :
- lignes 2-7 : les champs du formulaire de connexion (cf. exemple 11), dont l'identifiant du captcha, transmis par un champ caché, et la saisie de l'utilisateur. Tous sont facultatifs ([required = false]) : un champ absent donne [null], au lieu d'une erreur 400, et c'est l'action qui décide quoi en faire. L'action reçoit aussi la requête (pour l'adresse IP et le captcha), la réponse (pour y déposer le cookie du jeton) et le modèle de la vue ;
- lignes 8-9 : un login absent devient une chaîne vide, débarrassée de ses espaces ; un mot de passe absent devient une chaîne vide ;
- lignes 13-17 : 1. un couple bloqué n'est même pas écouté : ni le captcha, ni le mot de passe ne sont examinés. L'erreur est un message à traduire (record [Message], dossier [models]) : une clé de traduction, et un paramètre, le nombre de minutes à attendre. [Map.of] fabrique un dictionnaire non modifiable ;
- lignes 20-23 : 2. le captcha. Un mauvais captcha compte comme un échec (cf. exemple 27) ;
- lignes 26-32 : 3. seulement alors, le mot de passe. Un login ou un mot de passe vide n'est pas soumis au calcul [bcrypt], coûteux : on prend directement une boîte vide ([Optional.empty()]). Login inconnu ou mot de passe faux : le même message, volontairement vague (cf. exemple 25), et un échec de plus ;
- lignes 34-36 : le succès : compteur remis à zéro, jeton déposé dans le cookie, redirection vers la page d'accueil du rôle. [user.get()] sort le compte de sa boîte, dont on sait qu'elle n'est pas vide ;
- lignes 42-44 : l'adresse IP du client, fournie par la connexion réseau. La méthode est publique et statique : [InscriptionController] et [EspacePatientController] s'en servent aussi pour le limiteur. Derrière un serveur intermédiaire (un proxy inverse comme [nginx]), cette adresse serait celle du proxy : le commentaire indique la propriété de [Spring Boot] à activer dans ce cas ;
- lignes 48-51 : la page de connexion, toujours avec un nouveau captcha : chaque image ne sert qu'une fois. Son modèle, le record [LoginModele] (dossier [models]), contient la clé de traduction du titre de la page, l'erreur éventuelle, le login saisi (jamais le mot de passe) et le captcha. Il est rangé dans le modèle sous le nom [modele], le nom que toutes les vues de l'application utilisent. La vue est [login] (fichier [templates/login.html]).
Le traitement d'une tentative avec un mauvais captcha se voit avec [curl] : la page de connexion est renvoyée (code 200), avec le login saisi, une nouvelle image, et le message d'erreur en bas de la page :
| curl -i -X POST -d "login=admin&password=admin&captchaId=inconnu&captcha=ABCDE" http://localhost:8081/login
|
| HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
...
value="admin" required>
...
<div class="zone-erreur" role="alert">
<div>Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.</div>
...
|
La réponse ne dit rien du mot de passe, qui n'a pas été examiné. Une connexion réussie, elle, ne peut pas être montrée avec [curl], puisqu'il faut lire l'image ; elle se voit dans le navigateur (cf. « Utilisation de l'application ») : la réponse au POST est une redirection (302) vers la page d'accueil du rôle, avec l'entête [Set-Cookie] qui dépose le jeton.
11.14. La couche web : les services communs
Avant les contrôleurs et les vues, voici ce qu'ils ont en commun : la traduction, les attributs communs à toutes les vues, les messages flash, le traitement des exceptions, le cache des listes de médecins et de clients, la préparation des formulaires à réafficher, et quelques fonctions utilitaires. Tous ces fichiers sont dans le dossier [web]. On y retrouve, réunies, la plupart des techniques des exemples 15 à 24.
11.14.1. web/Traducteur.java
Le fichier est présenté en deux extraits.
| // =========================================================================
// web/Traducteur.java
// -------------------------------------------------------------------------
// Traduction français / anglais de l'interface (cf. exemple 22).
//
// Les dictionnaires sont les MÊMES fichiers que ceux des versions NestJS et
// ASP.NET Core de l'application (i18n/fr.json, i18n/en.json), lus une seule
// fois au démarrage.
//
// Le bean s'appelle [messageSource] et implémente l'interface MessageSource
// de Spring : c'est donc lui que Thymeleaf utilise pour les expressions
// #{CLE} des vues. Les paramètres d'un texte ({nom}, {minutes}...) sont
// passés de deux façons :
// #{ESPACE.HELLO('nom', ${patient})} par couples (nom, valeur) ;
// #{${message.cle}(${message.parametres})} par un dictionnaire (Map).
//
// LA LANGUE COURANTE : c'est le LocaleResolver (cf. config/WebConfig.java)
// qui la détermine à chaque requête, d'après un cookie. Spring la range dans
// LocaleContextHolder, et la transmet à Thymeleaf.
// =========================================================================
package rdvmedecins.web;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Component("messageSource")
public class Traducteur implements MessageSource {
public static final List<String> LANGUES = List.of("fr", "en");
public static final String LANGUE_PAR_DEFAUT = "fr";
// {nom} : un paramètre dans un texte
private static final Pattern PARAMETRE = Pattern.compile("\\{(\\w+)\\}");
// langue -> (clé complète "LOGIN.TITLE" -> texte)
private final Map<String, Map<String, String>> dictionnaires = new HashMap<>();
public Traducteur() throws IOException {
ObjectMapper json = new ObjectMapper();
for (String langue : LANGUES) {
try (InputStream flux = new ClassPathResource("i18n/" + langue + ".json").getInputStream()) {
Map<String, String> dictionnaire = new HashMap<>();
aplatir(json.readTree(flux), "", dictionnaire);
dictionnaires.put(langue, dictionnaire);
}
}
}
// la langue gérée qui correspond à [locale] : "en_US" -> "en" ; "de" -> "fr"
public static String langue(Locale locale) {
String langue = locale == null ? "" : locale.getLanguage();
return LANGUES.contains(langue) ? langue : LANGUE_PAR_DEFAUT;
}
// -----------------------------------------------------------------------
// traduire("MEDECINS.ADDED", { nom: "Marie PELISSIER" }, fr)
// -> cherche "MEDECINS.ADDED" dans le dictionnaire de la langue, et
// remplace {nom} par sa valeur. Une clé absente de la langue demandée
// est cherchée en français ; absente partout, on affiche la clé
// elle-même (on repère ainsi immédiatement un oubli).
// -----------------------------------------------------------------------
public String traduire(String cle, Map<String, String> parametres, Locale locale) {
if (cle == null || cle.isEmpty()) {
return "";
}
String texte = dictionnaires.get(langue(locale)).get(cle);
if (texte == null) {
texte = dictionnaires.get(LANGUE_PAR_DEFAUT).getOrDefault(cle, cle);
}
if (parametres == null || parametres.isEmpty()) {
return texte;
}
Matcher m = PARAMETRE.matcher(texte);
return m.replaceAll(r -> Matcher.quoteReplacement(parametres.getOrDefault(r.group(1), r.group())));
}
|
C'est le service de traduction de l'exemple 22. Les dictionnaires [i18n/fr.json] et [i18n/en.json] sont les mêmes fichiers que ceux de la version [ASP.NET Core], lus une seule fois au démarrage (cf. « Les dictionnaires »). Commentons ce code :
- lignes 40-41 : la classe implémente l'interface [MessageSource] de [Spring], celle des services qui fournissent les textes de l'interface, et le bean porte le nom [messageSource] (l'argument de [@Component]). C'est sous ce nom que [Spring] cherche le [MessageSource] de l'application : notre bean remplace donc celui que [Spring Boot] aurait configuré, et c'est lui que [Thymeleaf] interroge pour chaque expression [#{CLE}] des vues (cf. exemple 22) ;
- lignes 43-44 : les langues gérées, et la langue par défaut. [List.of] fabrique une liste non modifiable ;
- ligne 47 : une expression régulière qui reconnaît un paramètre dans un texte : [{], un ou plusieurs caractères de mot ([\w+], capturés par les parenthèses), [}]. Dans une chaîne [Java], la barre oblique inverse s'écrit [\\]. Le motif est compilé une seule fois ([Pattern.compile]), puis réutilisé ;
- ligne 50 : pour chaque langue, un dictionnaire « clé complète → texte ». Le bean est un singleton utilisé par des requêtes simultanées ; un simple [HashMap] convient pourtant, car les dictionnaires, remplis par le constructeur, ne sont ensuite que lus ;
- lignes 52-61 : le constructeur lit les deux fichiers JSON. [ClassPathResource] désigne un fichier du « classpath », l'ensemble des dossiers et archives où la machine virtuelle cherche les classes : le dossier [src/main/resources] y est recopié à la compilation. [ObjectMapper] est l'outil de lecture et d'écriture du JSON de la bibliothèque [Jackson] (version 3, d'où le paquetage [tools.jackson]). [readTree] rend l'arbre du document JSON, que la méthode [aplatir] transforme en dictionnaire. La construction [try (...)] ferme le flux à la fin du bloc, même en cas d'exception. Si un fichier manque, l'exception [IOException], déclarée par [throws], empêche l'application de démarrer ;
- lignes 64-67 : la langue gérée qui correspond à une [Locale], l'objet [Java] qui décrit une langue et, éventuellement, un pays : [en_US] donne [en], une langue non gérée donne [fr] ;
- lignes 76-89 : la traduction. Une clé absente du dictionnaire anglais est cherchée dans le dictionnaire français ; absente partout, c'est la clé elle-même qui est rendue ([getOrDefault(cle, cle)]) : un oubli se repère aussitôt à l'écran. Les paramètres [{nom}] du texte sont ensuite remplacés par leurs valeurs (ligne 88) : [replaceAll] appelle la lambda pour chaque paramètre trouvé ; [r.group(1)] est le nom capturé entre les accolades, [r.group()] le texte trouvé en entier, [{nom}], laissé tel quel si aucune valeur n'est fournie. [Matcher.quoteReplacement] protège les caractères [$] et [\], qui auraient sinon un sens particulier pour [replaceAll].
| // les paramètres passés par Thymeleaf (ou par Spring) : un seul
// dictionnaire, ou des couples (nom, valeur)
@SuppressWarnings("unchecked")
private static Map<String, String> parametres(Object[] args) {
Map<String, String> parametres = new HashMap<>();
if (args == null || args.length == 0) {
return parametres;
}
if (args.length == 1 && args[0] instanceof Map<?, ?> dictionnaire) {
dictionnaire.forEach((nom, valeur) -> parametres.put(String.valueOf(nom), String.valueOf(valeur)));
return parametres;
}
for (int i = 0; i + 1 < args.length; i += 2) {
parametres.put(String.valueOf(args[i]), String.valueOf(args[i + 1]));
}
return parametres;
}
// { "LOGIN": { "TITLE": "Connexion" } } -> { "LOGIN.TITLE": "Connexion" }
private static void aplatir(JsonNode noeud, String prefixe, Map<String, String> resultat) {
for (Map.Entry<String, JsonNode> propriete : noeud.properties()) {
String cle = prefixe.isEmpty() ? propriete.getKey() : prefixe + "." + propriete.getKey();
if (propriete.getValue().isObject()) {
aplatir(propriete.getValue(), cle, resultat);
} else {
resultat.put(cle, propriete.getValue().asString());
}
}
}
// ------------------------------------------------ l'interface MessageSource
@Override
public String getMessage(String code, Object[] args, String defaultMessage, Locale locale) {
return traduire(code, parametres(args), locale);
}
@Override
public String getMessage(String code, Object[] args, Locale locale) {
return traduire(code, parametres(args), locale);
}
// un message décrit par plusieurs codes possibles (une erreur de
// validation, par exemple) : le premier code présent dans le dictionnaire
// gagne ; sinon, le message par défaut, qui est lui-même une clé
@Override
public String getMessage(MessageSourceResolvable message, Locale locale) {
Map<String, String> dictionnaire = dictionnaires.get(langue(locale));
String[] codes = message.getCodes() == null ? new String[0] : message.getCodes();
for (String code : codes) {
if (dictionnaire.containsKey(code)) {
return traduire(code, parametres(message.getArguments()), locale);
}
}
return traduire(message.getDefaultMessage(), Map.of(), locale);
}
}
|
Commentons ce code :
- lignes 5-18 : les paramètres d'un message arrivent sous la forme d'un tableau d'objets ([Object[]]), de deux façons possibles. Une vue qui écrit [#{ESPACE.HELLO('nom', ${patient})}] passe des couples (nom, valeur) (lignes 14-16). Une vue qui affiche un message flash ou une erreur écrit [#{${message.cle}(${message.parametres})}] : elle passe un seul argument, le dictionnaire des paramètres (lignes 10-13). [instanceof Map<?, ?> dictionnaire] teste le type et range l'objet converti dans la variable [dictionnaire] ; [<?, ?>] signifie « des clés et des valeurs de types quelconques ». [String.valueOf] convertit tout objet en texte ;
- lignes 21-30 : l'aplatissement du JSON : [{ "LOGIN": { "TITLE": "Connexion" } }] devient [{ "LOGIN.TITLE": "Connexion" }]. La méthode est récursive : elle s'appelle elle-même pour chaque objet imbriqué, en allongeant le préfixe de la clé ;
- lignes 34-42 : les deux méthodes principales de l'interface [MessageSource] : un code (la clé), ses arguments, la langue. [@Override] demande au compilateur de vérifier que la méthode redéfinit bien une méthode de l'interface. Le message par défaut prévu par la première est ignoré : une clé absente s'affiche elle-même ;
- lignes 47-57 : la troisième méthode reçoit un message décrit par plusieurs codes possibles, ce que [Spring] produit par exemple pour une erreur de validation ou de conversion ([typeMismatch.version], [typeMismatch]...). Le premier code présent dans le dictionnaire gagne ; sinon, on traduit le message par défaut. Dans cette application, les messages des contraintes de validation sont des clés de traduction (cf. [models/Formulaires.java]) : le message par défaut est donc lui-même une clé.
La langue de la requête n'apparaît nulle part dans les contrôleurs : c'est le [LocaleResolver] (bean [localeResolver] de [config/WebConfig.java], cf. « La configuration du projet ») qui la lit dans le cookie [rdvmedecins_langue] au début de chaque requête ; [Spring] la transmet ensuite à [Thymeleaf], qui la passe à [getMessage].
11.14.2. web/ModeleCommun.java
| // =========================================================================
// web/ModeleCommun.java
// -------------------------------------------------------------------------
// Ce dont TOUTES les vues ont besoin, quelle que soit la page (cf. exemple
// 15) : @ControllerAdvice fait de cette classe un complément de tous les
// contrôleurs, et chaque méthode @ModelAttribute, exécutée avant chaque
// action, ajoute un attribut au modèle de la vue :
// - utilisateur : l'utilisateur connecté (ou null), pour la barre de navigation ;
// - langue : "fr" ou "en" ;
// - chemin : l'URL demandée (menu actif de la barre de navigation,
// lien "Autre image" du captcha).
// Le message flash, lui, est ajouté au modèle par Spring MVC lui-même,
// sous le nom [flash] (cf. CookieFlashMapManager.java).
// =========================================================================
package rdvmedecins.web;
import java.util.Locale;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import jakarta.servlet.http.HttpServletRequest;
import rdvmedecins.auth.AuthService;
import rdvmedecins.auth.UtilisateurConnecte;
@ControllerAdvice
public class ModeleCommun {
private final AuthService authService;
public ModeleCommun(AuthService authService) {
this.authService = authService;
}
@ModelAttribute("utilisateur")
public UtilisateurConnecte utilisateur(HttpServletRequest request) {
return authService.utilisateur(request);
}
// Locale : la langue de la requête, fournie par Spring (cf. config/WebConfig.java)
@ModelAttribute("langue")
public String langue(Locale locale) {
return Traducteur.langue(locale);
}
@ModelAttribute("chemin")
public String chemin(HttpServletRequest request) {
return request.getRequestURI();
}
}
|
Toutes les pages affichent la même barre de navigation : le nom de l'utilisateur connecté, les menus de son rôle, le menu courant mis en valeur, les boutons FR / EN. Il faut donc que le modèle de chaque vue contienne l'utilisateur, la langue et l'URL demandée. Plutôt que de les ajouter dans chaque action, on les ajoute une fois pour toutes, comme dans l'exemple 15 :
- ligne 26 : [@ControllerAdvice] fait de la classe un complément de tous les contrôleurs (cf. exemples 15 et 24) ;
- lignes 35-38 : une méthode [@ModelAttribute("utilisateur")] est exécutée avant chaque action, et la valeur qu'elle rend est ajoutée au modèle sous le nom donné. Ici, l'utilisateur connecté, ou [null], obtenu par [AuthService.utilisateur] (cf. [auth/AuthService.java]) : la barre de navigation écrit [${utilisateur.nom}], [${utilisateur.estAdmin()}]... Comme les actions, ces méthodes reçoivent en paramètre ce dont elles ont besoin, ici la requête ;
- lignes 41-44 : la langue courante ([fr] ou [en]), pour l'attribut [lang] de la page et les boutons FR / EN. Un paramètre de type [Locale] reçoit la langue de la requête, fixée par le [LocaleResolver] ;
- lignes 47-49 : le chemin de l'URL demandée, par exemple [/mon-espace] : la barre de navigation met en valeur le menu dont le chemin commence ainsi (cf. exemple 16), et le lien « Autre image » du captcha recharge la page courante.
Le message flash, quatrième donnée commune, n'a pas besoin de méthode : [Spring MVC] l'ajoute lui-même au modèle (cf. [CookieFlashMapManager.java], plus bas). En [ASP.NET Core MVC], ces données étaient des propriétés d'une classe mère commune à toutes les vues ; [Thymeleaf] n'a pas de classe mère des vues, et c'est le modèle de chaque vue qui les reçoit.
11.14.3. web/TypeFlash.java
| // =========================================================================
// web/TypeFlash.java - les deux sortes de messages flash (cf. Flash.java)
// =========================================================================
package rdvmedecins.web;
public enum TypeFlash {
SUCCESS, // classe Bootstrap alert-success : en haut de la page
DANGER // une erreur : en bas de la page (cf. templates/fragments/erreurs.html)
}
|
- lignes 6-9 : les deux types de messages flash, sous forme d'une énumération : un type dont les valeurs possibles sont fixées une fois pour toutes. Un succès est affiché en haut de la page (fragment [templates/fragments/flash.html]), une erreur en bas (fragment [templates/fragments/erreurs.html], cf. « Les vues »).
11.14.4. web/MessageFlash.java
| // =========================================================================
// web/MessageFlash.java
// -------------------------------------------------------------------------
// Un message "flash" : un message à afficher UNE fois, sur la page SUIVANTE
// (cf. exemple 20). cle : clé de traduction ; parametres : ses paramètres
// ({nom}, {login}...).
// =========================================================================
package rdvmedecins.web;
import java.util.Map;
public record MessageFlash(TypeFlash type, String cle, Map<String, String> parametres) {
// le nom de l'attribut flash, dans le modèle des vues
public static final String ATTRIBUT = "flash";
public static MessageFlash succes(String cle) {
return new MessageFlash(TypeFlash.SUCCESS, cle, Map.of());
}
public static MessageFlash succes(String cle, Map<String, String> parametres) {
return new MessageFlash(TypeFlash.SUCCESS, cle, parametres);
}
public static MessageFlash erreur(String cle) {
return new MessageFlash(TypeFlash.DANGER, cle, Map.of());
}
public static MessageFlash erreur(String cle, Map<String, String> parametres) {
return new MessageFlash(TypeFlash.DANGER, cle, parametres);
}
public boolean estSucces() {
return type == TypeFlash.SUCCESS;
}
}
|
- ligne 12 : un message flash (cf. exemple 20) est un type, une clé de traduction et ses paramètres, et non un texte déjà traduit : il sera traduit par la page suivante, dans la langue de celle-ci ;
- ligne 15 : le nom sous lequel le message voyage, et sous lequel les vues le trouvent dans leur modèle : [flash]. Un contrôleur écrit par exemple [redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.succes("AGENDA.CANCELLED"))] ;
- lignes 17-31 : quatre méthodes statiques qui fabriquent un message, avec ou sans paramètres. Des méthodes de même nom qui diffèrent par leurs paramètres sont des surcharges : le compilateur choisit la bonne selon les arguments. [Map.of()] est un dictionnaire vide ;
- lignes 33-35 : un test pour les vues : [${flash.estSucces()}].
11.14.5. web/CookieFlashMapManager.java
| // =========================================================================
// web/CookieFlashMapManager.java
// -------------------------------------------------------------------------
// Les messages "flash" : un message à afficher UNE fois, sur la page
// SUIVANTE. C'est le complément naturel du schéma "Post / Redirect / Get"
// utilisé par tous les formulaires de l'application : un POST réussi ne
// fabrique pas de page, il redirige (302) vers une page GET ; le message,
// lui, doit survivre à la redirection.
//
// Spring MVC l'offre en standard : un contrôleur dépose un attribut flash
// (RedirectAttributes.addFlashAttribute, cf. exemple 20), qui se retrouve
// dans le modèle de la requête SUIVANTE, puis disparaît. Par défaut, Spring
// le conserve dans la SESSION HTTP (SessionFlashMapManager). Mais notre
// serveur est SANS ÉTAT : il ne garde rien entre deux requêtes (le jeton
// JWT est dans un cookie). On remplace donc le "gestionnaire des attributs
// flash" (FlashMapManager) de Spring par le nôtre, qui les range dans un
// COOKIE - c'est le bean [flashMapManager] de config/WebConfig.java.
//
// AbstractFlashMapManager fait le gros du travail (à quelle requête le
// message est destiné, expiration...) : il ne reste qu'à lire et écrire la
// liste des attributs flash. On n'en garde qu'un, sous forme de texte JSON
// codé en base64 (un cookie ne peut pas contenir n'importe quel caractère).
//
// Le cookie n'est ni chiffré ni signé : il ne contient qu'une CLÉ de
// traduction et ses paramètres, affichés échappés. Un utilisateur qui le
// modifierait ne changerait que le message qu'il voit lui-même.
// =========================================================================
package rdvmedecins.web;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.web.servlet.FlashMap;
import org.springframework.web.servlet.support.AbstractFlashMapManager;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.ObjectMapper;
public class CookieFlashMapManager extends AbstractFlashMapManager {
public static final String COOKIE = "rdvmedecins_flash";
// ce que contient le cookie : la page à qui le message est destiné, la
// date d'expiration (en millisecondes), le message
private record ContenuCookie(String chemin, long expire, MessageFlash flash) {
}
private final ObjectMapper json = new ObjectMapper();
// les attributs flash de la requête précédente (null : aucun)
@Override
protected List<FlashMap> retrieveFlashMaps(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, COOKIE);
if (cookie == null) {
return null;
}
try {
byte[] octets = Base64.getUrlDecoder().decode(cookie.getValue());
ContenuCookie contenu = json.readValue(octets, ContenuCookie.class);
FlashMap flashMap = new FlashMap();
flashMap.put(MessageFlash.ATTRIBUT, contenu.flash());
flashMap.setTargetRequestPath(contenu.chemin());
flashMap.setExpirationTime(contenu.expire());
// une liste MODIFIABLE : Spring en retire lui-même les messages
// consommés (List.of(...) donnerait une liste non modifiable)
return new ArrayList<>(List.of(flashMap));
} catch (RuntimeException e) {
return null; // cookie illisible : on l'ignore
}
}
// la liste à conserver pour les requêtes suivantes : vide -> on efface le cookie
@Override
protected void updateFlashMaps(List<FlashMap> flashMaps, HttpServletRequest request,
HttpServletResponse response) {
ResponseCookie cookie;
if (flashMaps.isEmpty() || !(flashMaps.getLast().get(MessageFlash.ATTRIBUT) instanceof MessageFlash flash)) {
cookie = ResponseCookie.from(COOKIE, "").path("/").maxAge(0).build();
} else {
FlashMap dernier = flashMaps.getLast();
ContenuCookie contenu = new ContenuCookie(dernier.getTargetRequestPath(), dernier.getExpirationTime(), flash);
String valeur = Base64.getUrlEncoder().withoutPadding()
.encodeToString(json.writeValueAsString(contenu).getBytes(StandardCharsets.UTF_8));
cookie = ResponseCookie.from(COOKIE, valeur).path("/").httpOnly(true).sameSite("Lax").build();
}
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
}
|
Dans l'exemple 20, les attributs flash de [Spring MVC] étaient conservés, entre la redirection et la page suivante, dans la session HTTP : un espace mémoire du serveur propre à chaque navigateur, repéré par le cookie [JSESSIONID]. Or notre serveur est sans état : il ne garde rien en mémoire entre deux requêtes, l'utilisateur étant reconnu par son jeton. Cette classe range donc les attributs flash dans un cookie, comme le faisait [TempData] dans la version [ASP.NET Core]. Commentons ce code :
- ligne 46 : [Spring MVC] confie la conservation des attributs flash à un bean de type [FlashMapManager]. [AbstractFlashMapManager] fait le gros du travail : il sait à quelle requête un attribut flash est destiné, quand il expire, quand il a été consommé. Une classe dérivée n'a que deux méthodes à écrire : lire la liste des attributs flash en attente (lignes 58-77) et l'enregistrer (lignes 80-94). Le bean [flashMapManager] de [config/WebConfig.java] crée un objet de cette classe ; il porte le nom sous lequel [Spring MVC] cherche ce gestionnaire, et remplace donc celui de [Spring MVC] ;
- lignes 52-53 : ce que contient le cookie : le chemin de la page à qui le message est destiné, sa date d'expiration (en millisecondes), et le message ;
- ligne 55 : [Jackson] transforme un objet en texte JSON et inversement, y compris un record ;
- lignes 58-77 : la lecture, appelée au début de chaque requête. Le cookie est décodé (base64, puis JSON), et le message est replacé dans un objet [FlashMap] (un dictionnaire d'attributs flash, avec son chemin cible et sa date d'expiration), le seul de la liste. La liste rendue doit être modifiable (lignes 71-73) : [Spring MVC] en retire lui-même le message quand il le consomme. Un cookie illisible (modifié à la main, par exemple) ne doit pas faire échouer la page : l'exception est interceptée et le cookie ignoré ;
- lignes 80-94 : l'enregistrement, appelé quand la liste a changé : un message a été déposé, consommé ou a expiré. Une liste vide efface le cookie ; sinon, le dernier message déposé est écrit dans le cookie. L'écriture [instanceof MessageFlash flash], placée dans la condition, teste le type de l'attribut et le range dans la variable [flash], utilisée ligne 88. Le texte JSON est codé en base64 ([Base64.getUrlEncoder()], variante sans les caractères [+] et [/]) car un cookie ne peut pas contenir n'importe quel caractère ; les guillemets et les accents du JSON y sont interdits. Le cookie n'a pas de durée de vie ([Max-Age]) : c'est un cookie de session du navigateur, et c'est la date d'expiration du contenu (trois minutes par défaut) qui compte ;
- lignes 24-26 : le cookie n'est ni chiffré ni signé, contrairement au cookie de [TempData] en [ASP.NET Core]. Ce n'est pas nécessaire : il ne contient qu'une clé de traduction et ses paramètres, affichés échappés par les vues ; un utilisateur qui le modifierait ne changerait que le message qu'il voit lui-même.
Nous avons vu, avec [SecuriteConfig], le dépôt d'un message « session expirée » dans ce cookie. Voici, décodé, le contenu du cookie utilisé dans la commande qui suit (relevé lors d'un autre essai que ci-dessus : seule la date d'expiration diffère) :
| {"chemin":"/login","expire":1790354993522,"flash":{"type":"DANGER","cle":"ERRORS.SESSION_EXPIRED","parametres":{}}}
|
La requête suivante, vers [/login], consomme le message : la page l'affiche, et la réponse efface le cookie.
| curl -i -H "Cookie: rdvmedecins_flash=eyJjaGVtaW4iOiIvbG9naW4iLCJleHBpcmUiOjE3OTAzNTQ5OTM1MjIsImZsYXNoIjp7InR5cGUiOiJEQU5HRVIiLCJjbGUiOiJFUlJPUlMuU0VTU0lPTl9FWFBJUkVEIiwicGFyYW1ldHJlcyI6e319fQ" http://localhost:8081/login
|
| HTTP/1.1 200
Set-Cookie: rdvmedecins_flash=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: text/html;charset=UTF-8
...
<div class="zone-erreur" role="alert">
<div>Votre session a expiré : reconnectez-vous.</div>
</div>
...
|
Le message n'est affiché qu'une fois : rechargée, la page ne le montre plus.
11.14.6. web/Flash.java
| // =========================================================================
// web/Flash.java
// -------------------------------------------------------------------------
// Déposer un message flash depuis N'IMPORTE OÙ, y compris en dehors de
// Spring MVC : l'authentification JWT (session expirée, cf.
// config/SecuriteConfig.java) s'exécute dans un filtre de Spring Security,
// AVANT Spring MVC - un RedirectAttributes n'y existe pas encore.
// On s'adresse alors directement au FlashMapManager (CookieFlashMapManager).
// Dans un contrôleur, on utilise plutôt RedirectAttributes.addFlashAttribute.
// =========================================================================
package rdvmedecins.web;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.FlashMap;
import org.springframework.web.servlet.FlashMapManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class Flash {
private final FlashMapManager gestionnaire;
public Flash(FlashMapManager gestionnaire) {
this.gestionnaire = gestionnaire;
}
// dépose [message], destiné à la page [cible] (la prochaine requête)
public void deposer(HttpServletRequest request, HttpServletResponse response, MessageFlash message, String cible) {
FlashMap flashMap = new FlashMap();
flashMap.put(MessageFlash.ATTRIBUT, message);
flashMap.setTargetRequestPath(cible);
gestionnaire.saveOutputFlashMap(flashMap, request, response);
}
}
|
Dans un contrôleur, un message flash se dépose avec [RedirectAttributes.addFlashAttribute] (cf. exemple 20). Le point d'entrée de [SecuriteConfig], lui, s'exécute dans un filtre de [Spring Security], avant que [Spring MVC] n'ait pris la requête en main :
- ligne 20 : [@Component] fait de la classe un bean, que [SecuriteConfig] reçoit en paramètre ;
- lignes 25-27 : le bean reçoit le gestionnaire des attributs flash, c'est-à-dire notre [CookieFlashMapManager] ;
- lignes 30-35 : on fabrique soi-même le [FlashMap] que [Spring MVC] fabrique d'ordinaire : le message, et le chemin de la page qui doit le recevoir ([/login]). [saveOutputFlashMap] le confie au gestionnaire, qui écrit le cookie dans la réponse.
11.14.7. web/HtmlExceptionHandler.java
| // =========================================================================
// web/HtmlExceptionHandler.java
// -------------------------------------------------------------------------
// Comment les erreurs deviennent des pages HTML. Trois mécanismes se
// partagent le travail (cf. exemple 24) :
//
// 1. cette classe : une SessionInvalideException (compte supprimé depuis
// la connexion) -> retour à la page de connexion ;
// 2. une réponse d'erreur 400, 403 ou 404 (ResponseStatusException levée
// par un contrôleur, rôle insuffisant, page inconnue...) est transmise
// par le serveur à l'adresse /error : ErreurController en fait une page ;
// 3. toute autre exception (une vraie anomalie) est inscrite dans le
// journal du serveur, puis transmise elle aussi à /error : l'utilisateur
// voit la page d'erreur 500 - jamais la pile d'appels.
// =========================================================================
package rdvmedecins.web;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.support.RequestContextUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.auth.AuthService;
import rdvmedecins.auth.SessionInvalideException;
@ControllerAdvice
public class HtmlExceptionHandler {
@ExceptionHandler(SessionInvalideException.class)
public String sessionInvalide(HttpServletRequest request, HttpServletResponse response) {
AuthService.effacerJeton(response);
// l'attribut flash de la redirection qui suit (l'équivalent de
// RedirectAttributes.addFlashAttribute, pour une méthode @ExceptionHandler)
RequestContextUtils.getOutputFlashMap(request)
.put(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.SESSION_EXPIRED"));
return "redirect:/login";
}
// les autres exceptions ne sont pas traitées ici : elles remontent
// jusqu'au serveur, qui les transmet à /error (page 500)
}
|
Le commentaire d'en-tête (lignes 4-14) décrit les trois mécanismes qui transforment les erreurs en pages HTML (cf. exemple 24). Deux d'entre eux ne demandent aucun code : une réponse d'erreur ([sendError(403)], une [ResponseStatusException] levée par un contrôleur, une URL inconnue) est transmise par le serveur à l'adresse [/error], où [ErreurController] fabrique la page ; une exception non traitée est inscrite dans le journal du serveur, avec sa pile d'appels, puis transmise elle aussi à [/error], qui affiche la page d'erreur 500. L'utilisateur ne voit jamais la pile d'appels, qui aiderait un attaquant (propriété [spring.web.error.include-stacktrace=never], cf. « La configuration du projet »). Reste un cas particulier, traité ici :
- ligne 27 : [@ControllerAdvice], comme pour [ModeleCommun] : les méthodes de la classe s'appliquent à tous les contrôleurs ;
- lignes 30-31 : une méthode [@ExceptionHandler] est appelée quand une action lève l'exception indiquée (cf. exemple 24). Elle peut recevoir la requête et la réponse, et rendre un nom de vue ou une redirection, comme une action ;
- lignes 32-37 : le compte de l'utilisateur connecté a été supprimé depuis sa connexion, alors que son jeton est encore valide (cf. [auth/SessionInvalideException.java]). On efface le cookie du jeton, on dépose un message flash « session expirée », et on redirige vers la page de connexion : l'utilisateur est traité comme si sa session avait expiré. Une méthode [@ExceptionHandler] ne peut pas recevoir de paramètre [RedirectAttributes] : [RequestContextUtils.getOutputFlashMap(request)] donne directement le [FlashMap] que la redirection transmettra à la page suivante.
11.14.8. web/ApplicationModel.java
Le fichier est présenté en deux extraits.
| // =========================================================================
// web/ApplicationModel.java
// -------------------------------------------------------------------------
// Le cache / façade déjà présent dans le serveur JSON (équivalent de la
// classe [ApplicationModel] du projet Spring d'origine) : les listes de
// médecins et de clients sont lues une fois au démarrage, puis servies
// depuis la mémoire.
//
// Ces listes peuvent CHANGER (l'ADMIN ajoute, modifie, supprime des
// médecins et des clients) : chaque écriture passe donc par cette façade,
// qui recharge la liste concernée juste après. C'est tout l'intérêt d'un
// point d'entrée UNIQUE vers la couche métier : la règle "écriture =>
// rechargement du cache" n'est écrite qu'ici.
//
// La façade est un bean SINGLETON, comme la couche métier elle-même : avec
// Spring, les DAO et la couche métier ne dépendent d'aucune requête (c'est
// Spring qui associe à chaque thread SA transaction et SA connexion). Le
// cache et la façade peuvent donc être le même objet.
//
// Spring traite les requêtes EN PARALLÈLE, sur plusieurs threads. On ne
// modifie donc jamais une liste en place : on la REMPLACE en entier par une
// nouvelle. L'échange d'une référence est atomique ; "volatile" garantit que
// tous les threads voient aussitôt la nouvelle liste.
// =========================================================================
package rdvmedecins.web;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.DonneesPersonne;
import rdvmedecins.metier.Metier;
// même contrat que la couche métier (Metier)
@Service
public class ApplicationModel implements Metier {
private final Metier metier;
private volatile List<Medecin> medecins = List.of();
private volatile List<Client> clients = List.of();
// deux beans implémentent Metier : MetierService et ApplicationModel
// lui-même. @Qualifier désigne celui qu'on veut, par son nom.
public ApplicationModel(@Qualifier("metierService") Metier metier) {
this.metier = metier;
}
// -----------------------------------------------------------------------
// chargement initial des deux listes. @PostConstruct : Spring appelle
// cette méthode une fois l'objet créé et ses dépendances injectées, au
// démarrage de l'application. Une erreur de lecture n'est pas attrapée :
// elle arrête le serveur, plutôt que de le laisser démarrer "à vide" (une
// base inaccessible l'arrête même plus tôt, dès le démarrage de Hibernate).
// -----------------------------------------------------------------------
@PostConstruct
public void charger() {
rechargerMedecins();
rechargerClients();
}
private void rechargerMedecins() {
medecins = List.copyOf(metier.getAllMedecins());
}
private void rechargerClients() {
clients = List.copyOf(metier.getAllClients());
}
// ------------------------------------------------ lectures depuis le cache
@Override
public List<Medecin> getAllMedecins() {
return medecins;
}
@Override
public List<Client> getAllClients() {
return clients;
}
// le médecin dont le compte utilisateur est [idUser] - sert à présélectionner,
// pour un médecin connecté, son propre agenda
public Optional<Medecin> getMedecinByUserId(int idUser) {
return medecins.stream().filter(m -> Objects.equals(m.getIdUser(), idUser)).findFirst();
}
|
Les listes des médecins et des clients sont affichées sur presque toutes les pages (listes déroulantes de l'agenda, pages d'administration). Plutôt que de les relire dans la base à chaque requête, on les garde en mémoire : c'est une donnée de portée application (cf. exemple 23). Commentons ce code :
- lignes 45-46 : la classe est un bean singleton qui implémente l'interface [Metier], celle de la couche métier (cf. « La couche métier ») : pour les contrôleurs, elle est la couche métier, et c'est elle seule qu'ils reçoivent (par exemple [public AgendaController(ApplicationModel application)]). C'est une façade : un point d'entrée unique qui cache ce qu'il y a derrière. En [ASP.NET Core], le cache devait être un service distinct de la façade, car la couche métier y vit le temps d'une requête ; avec [Spring], la couche métier est elle-même un singleton (c'est [Spring] qui associe à chaque [thread] sa transaction et sa connexion à la base) : un seul objet suffit (lignes 15-18) ;
- ligne 48 : la vraie couche métier, [MetierService], à qui la façade transmet les demandes ;
- lignes 50-51 : le cache lui-même : les deux listes, vides au départ ([List.of()]). [Spring] traite les requêtes en parallèle, sur plusieurs [threads]. Une liste partagée ne doit donc jamais être modifiée pendant qu'une autre requête la parcourt. La règle choisie est simple : on ne modifie jamais une liste, on la remplace par une nouvelle. Le remplacement d'une référence se fait en une seule opération, et le mot-clé [volatile] garantit que tous les [threads] voient aussitôt la nouvelle liste ; sans lui, un [thread] pourrait continuer à lire une ancienne valeur, gardée dans la mémoire cache de son processeur ;
- lignes 55-57 : deux beans ont le type [Metier] : [MetierService] et [ApplicationModel] lui-même. Demander simplement « un [Metier] » serait ambigu, et [Spring] refuserait de démarrer. [@Qualifier("metierService")] désigne le bean voulu par son nom ; un bean déclaré par [@Service] porte par défaut le nom de sa classe, avec une minuscule initiale ;
- lignes 66-70 : le chargement initial des deux listes. [@PostConstruct] fait appeler la méthode par [Spring] une fois l'objet créé et ses dépendances injectées, pendant le démarrage de l'application, avant que le serveur n'accepte la moindre requête. Une erreur de lecture n'est pas attrapée : elle arrête l'application, ce qui vaut mieux qu'un serveur démarré avec des listes vides (une base inaccessible arrête d'ailleurs l'application plus tôt encore, dès le démarrage d'[Hibernate], cf. « Installation et exécution ») ;
- lignes 72-78 : le rechargement d'une liste : on lit la liste complète dans la base, et on remplace celle du cache. [List.copyOf] fabrique une copie non modifiable de la liste ;
- lignes 82-90 : les listes sont servies depuis la mémoire. La liste rendue est celle du cache elle-même : comme elle n'est pas modifiable, un appelant qui tenterait d'y ajouter un élément provoquerait une exception, et le cache ne peut pas être altéré par erreur ;
- lignes 94-96 : le médecin d'un compte, cherché dans le cache : un médecin connecté voit d'emblée son propre agenda. Le flux des médecins est filtré ([filter]), et [findFirst] rend le premier médecin retenu, dans un [Optional], vide si aucun ne convient. [getIdUser()] rend un [Integer] (un objet), et [idUser] est un [int] : [Objects.equals] compare les deux valeurs sans risque d'erreur, même si l'[Integer] valait [null].
| // ------------------------------------------------ simples délégations
@Override
public List<Creneau> getAllCreneaux(int idMedecin) {
return metier.getAllCreneaux(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(int idMedecin, LocalDate jour) {
return metier.getRvMedecinJour(idMedecin, jour);
}
@Override
public Optional<Client> getClientById(int id) {
return metier.getClientById(id);
}
@Override
public Optional<Medecin> getMedecinById(int id) {
return metier.getMedecinById(id);
}
@Override
public Optional<Rv> getRvById(int id) {
return metier.getRvById(id);
}
@Override
public Optional<Creneau> getCreneauById(int id) {
return metier.getCreneauById(id);
}
@Override
public Optional<AgendaMedecinJour> getAgendaMedecinJour(int idMedecin, LocalDate jour) {
return metier.getAgendaMedecinJour(idMedecin, jour);
}
// pas de cache ici : c'est la donnée la plus récente qui compte (un patient
// qui vient de modifier ou de supprimer son compte)
@Override
public Optional<Client> getClientByUserId(int idUser) {
return metier.getClientByUserId(idUser);
}
@Override
public List<Rv> getRvAVenirClient(int idClient) {
return metier.getRvAVenirClient(idClient);
}
@Override
public Rv ajouterRv(LocalDate jour, Creneau creneau, Client client) {
return metier.ajouterRv(jour, creneau, client);
}
@Override
public void supprimerRv(Rv rv) {
metier.supprimerRv(rv);
}
// ------------------------------------------------ écritures => rechargement
@Override
public Medecin ajouterMedecin(DonneesPersonne donnees) {
Medecin medecin = metier.ajouterMedecin(donnees);
rechargerMedecins();
return medecin;
}
@Override
public Medecin modifierMedecin(int id, DonneesPersonne donnees) {
Medecin medecin = metier.modifierMedecin(id, donnees);
rechargerMedecins();
return medecin;
}
@Override
public void supprimerMedecin(int id) {
metier.supprimerMedecin(id);
rechargerMedecins();
}
@Override
public Client ajouterClient(DonneesPersonne donnees) {
Client client = metier.ajouterClient(donnees);
rechargerClients();
return client;
}
@Override
public Client modifierClient(int id, DonneesPersonne donnees) {
Client client = metier.modifierClient(id, donnees);
rechargerClients();
return client;
}
@Override
public void supprimerClient(int id) {
metier.supprimerClient(id);
rechargerClients();
}
}
|
Commentons ce code :
- lignes 3-58 : les autres demandes sont relayées telles quelles à la couche métier ;
- lignes 38-43 : le client d'un compte n'est volontairement pas cherché dans le cache : un patient qui vient de modifier ou de supprimer son compte doit être vu tel qu'il est dans la base ;
- lignes 60-101 : toute écriture sur les médecins ou les clients est suivie du rechargement de la liste concernée. Puisque ce bean est le seul point d'entrée des contrôleurs vers la couche métier, la règle « écriture = rechargement du cache » n'est écrite qu'ici, et le cache ne peut pas devenir périmé. Si la couche métier refuse l'opération ([MetierException]), l'exception interrompt la méthode avant le rechargement, devenu inutile.
11.14.9. web/Validation.java
| // =========================================================================
// web/Validation.java
// -------------------------------------------------------------------------
// Ce qu'il faut pour RÉAFFICHER un formulaire refusé : les erreurs de
// saisie champ par champ, et les champs à encadrer en rouge.
// =========================================================================
package rdvmedecins.web;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import rdvmedecins.models.ModelesVues.EtatFormulaire;
import rdvmedecins.models.ModelesVues.Message;
public final class Validation {
private Validation() {
}
// -----------------------------------------------------------------------
// BindingResult (rempli par Spring lors de la liaison et de la validation
// du formulaire) -> le dictionnaire champ -> clé du message, en ne gardant
// que le premier message de chaque champ : { "nom": "ERRORS.LASTNAME_REQUIRED", ... }
//
// Une erreur de CONVERSION (un texte là où un nombre est attendu :
// version="abc") a le code "typeMismatch" et un message technique, en
// anglais : on la remplace par la clé ERRORS.INVALID_FORM.
// -----------------------------------------------------------------------
public static Map<String, String> erreursDe(BindingResult resultat) {
Map<String, String> erreurs = new HashMap<>();
for (FieldError erreur : resultat.getFieldErrors()) {
String cle = erreur.isBindingFailure() || erreur.getDefaultMessage() == null
? "ERRORS.INVALID_FORM"
: erreur.getDefaultMessage();
erreurs.putIfAbsent(erreur.getField(), cle);
}
return erreurs;
}
// -----------------------------------------------------------------------
// les champs du formulaire concernés par chaque erreur métier "de saisie"
// (un doublon) : ils sont encadrés en rouge, le message étant en bas de page
// -----------------------------------------------------------------------
public static final Map<String, List<String>> CHAMPS_ERREURS_METIER = Map.of(
"ERRORS.LOGIN_TAKEN", List.of("login"),
"ERRORS.DOCTOR_ALREADY_EXISTS", List.of("prenom", "nom"),
"ERRORS.PATIENT_ALREADY_EXISTS", List.of("prenom", "nom"));
// l'état d'un formulaire à (ré)afficher : un message sous chaque champ
// refusé par la validation (erreurs), et les champs à encadrer en rouge,
// ceux-là plus d'éventuels autres (autresChamps)
public static EtatFormulaire etat(Map<String, String> erreurs, Collection<String> autresChamps) {
Set<String> champs = new HashSet<>(erreurs.keySet());
if (autresChamps != null) {
champs.addAll(autresChamps);
}
return new EtatFormulaire(erreurs, champs);
}
// LE message d'erreur de la page, affiché en bas de page : [erreur] s'il
// y en a une, sinon "Le formulaire contient des erreurs." s'il y a des
// erreurs de champ, sinon rien
public static Message messagePage(Map<String, String> erreurs, Message erreur) {
if (erreur != null) {
return erreur;
}
return erreurs.isEmpty() ? null : Message.de("ERRORS.INVALID_FORM");
}
}
|
Quand un formulaire est refusé, la page est réaffichée avec la saisie, un message sous chaque champ fautif, les champs fautifs encadrés en rouge, et un message en bas de la page. Cette classe prépare ces informations pour les contrôleurs :
- lignes 22-25 : une classe utilitaire : [final] (on ne peut pas en dériver), un constructeur privé (on ne peut pas en créer d'objet), et seulement des méthodes statiques ;
- lignes 36-45 : transforme le [BindingResult] (cf. exemples 09 et 19), rempli par [Spring] lors de la liaison et de la validation du formulaire, en un dictionnaire simple « champ → clé du message ». [putIfAbsent] n'ajoute une entrée que si le champ n'en a pas encore : seul le premier message de chaque champ est gardé, un seul message s'affiche sous le champ. Les messages des contraintes de validation sont des clés de traduction (cf. [models/Formulaires.java]) ; une erreur de conversion ([isBindingFailure()] : un texte là où un nombre est attendu), elle, a un message technique, en anglais, remplacé par la clé [ERRORS.INVALID_FORM] ;
- lignes 51-54 : certaines erreurs de la couche métier concernent des champs précis : un login déjà pris, un médecin ou un client homonyme. Ces champs seront encadrés en rouge, le message étant affiché en bas de la page. [Map.of] fabrique le dictionnaire en une expression ;
- lignes 59-65 : l'état du formulaire à afficher : les messages sous les champs, et les champs à encadrer en rouge, c'est-à-dire ceux qui ont un message, plus les [autresChamps] (ceux d'une erreur métier). Un [HashSet] est un ensemble : chaque élément n'y figure qu'une fois. Le résultat est un record [EtatFormulaire] (cf. [models/ModelesVues.java]) ;
- lignes 70-75 : le message du bas de la page : l'erreur donnée s'il y en a une ; sinon, s'il y a des erreurs de champ, le message « Le formulaire contient des erreurs. » ; sinon, rien ([null]).
11.14.10. web/Outils.java
| // =========================================================================
// web/Outils.java
// -------------------------------------------------------------------------
// Petites fonctions utilitaires de la couche web :
// - libellés : "Mme Alice DURAND" ;
// - dates : "yyyy-MM-dd", heures "8:05", jours "mercredi 23 septembre 2026" ;
// - saisie : lecture d'un entier positif envoyé par le navigateur ;
// - patient : le patient (entité Client) de l'utilisateur connecté.
//
// Une classe "utilitaire" : que des méthodes static, on ne crée jamais
// d'objet de cette classe (constructeur privé).
// =========================================================================
package rdvmedecins.web;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Locale;
import java.util.Optional;
import org.springframework.security.oauth2.jwt.Jwt;
import rdvmedecins.auth.SessionInvalideException;
import rdvmedecins.auth.UtilisateurConnecte;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Personne;
public final class Outils {
private Outils() {
}
// ----------------------------------------------------------------- libellés
// "Mme Alice DURAND"
public static String libelle(Personne p) {
return p.getTitre() + " " + p.getPrenom() + " " + p.getNom();
}
// ----------------------------------------------------------------- dates
// le format des dates dans les URL et dans le champ <input type="date"> ;
// STRICT : "2026-02-30" est refusé (et non ramené au 2 mars)
private static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
// la date, si [texte] est une date qui existe au format yyyy-MM-dd
public static Optional<LocalDate> dateValide(String texte) {
try {
return Optional.of(LocalDate.parse(texte, ISO));
} catch (DateTimeParseException | NullPointerException e) {
return Optional.empty();
}
}
public static String iso(LocalDate jour) {
return jour.format(ISO);
}
// "8:05" plutôt que "8:5"
public static String formaterHeure(int h, int m) {
return "%d:%02d".formatted(h, m);
}
public static String horaire(Creneau c) {
return formaterHeure(c.getHdebut(), c.getMdebut()) + " - " + formaterHeure(c.getHfin(), c.getMfin());
}
// "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la
// langue de la requête
public static String formaterJour(LocalDate jour, Locale langue) {
return jour.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", langue));
}
// ----------------------------------------------------------------- saisie
// "3" -> 3 ; "", "abc", "-2", "+2", null -> null
public static Integer entierPositif(String valeur) {
if (valeur == null || !valeur.matches("\\d{1,9}")) {
return null;
}
int n = Integer.parseInt(valeur);
return n > 0 ? n : null;
}
// ----------------------------------------------------------------- utilisateur
// l'utilisateur décrit par le jeton de la requête (paramètre
// @AuthenticationPrincipal Jwt jeton d'une action). Un jeton valide mais
// incomplet (claims absents) est traité comme une session expirée.
public static UtilisateurConnecte utilisateur(Jwt jeton) {
UtilisateurConnecte utilisateur = UtilisateurConnecte.de(jeton);
if (utilisateur == null) {
throw new SessionInvalideException();
}
return utilisateur;
}
// ----------------------------------------------------------------- patient
// -----------------------------------------------------------------------
// Le patient (entité Client) qui correspond à l'utilisateur connecté.
//
// Le jeton JWT ne contient que l'id du COMPTE (users.id). Pour un rôle
// USER, on retrouve le client correspondant grâce à la colonne clients.user.
//
// S'il n'y en a pas, c'est que le compte a été supprimé depuis la
// connexion alors que son jeton est encore valide : on le traite comme une
// session expirée (cf. SessionInvalideException).
// -----------------------------------------------------------------------
public static Client patientConnecte(ApplicationModel application, UtilisateurConnecte utilisateur) {
if (utilisateur == null) {
throw new SessionInvalideException();
}
return application.getClientByUserId(utilisateur.userId()).orElseThrow(SessionInvalideException::new);
}
}
|
Commentons ce code :
- lignes 38-40 : le libellé d'un médecin ou d'un client : « Mme Alice DURAND ». Le paramètre est de type [Personne], la classe mère des entités [Medecin] et [Client] (cf. « Les entités ») : la même fonction sert aux deux ;
- lignes 46-47 : le format des dates dans les URL et dans le champ [<input type="date">]. Le mode [STRICT] refuse une date qui n'existe pas : [2026-02-30] est une erreur, au lieu d'être ramené au 2 mars (cf. exemple 12). Dans ce mode, l'année s'écrit [uuuu] : [yyyy] désignerait l'année « de l'ère », qui exigerait d'indiquer aussi l'ère (avant ou après J.-C.) ;
- lignes 50-56 : la date, si le texte est une date valide au format [yyyy-MM-dd], dans un [Optional], vide sinon. Un texte [null] lève une [NullPointerException], interceptée comme une date mal écrite : l'écriture [catch (A | B e)] intercepte deux types d'exceptions ;
- lignes 58-60 : l'opération inverse : une date vers le texte [yyyy-MM-dd] ;
- lignes 63-69 : une heure au format [8:05] (le format [%02d] écrit un entier sur deux chiffres au moins, complété par des zéros), et l'horaire d'un créneau : [8:00 - 8:20] ;
- lignes 73-75 : un jour en toutes lettres, dans la langue de la requête, que le contrôleur reçoit en paramètre ([Locale]) : « mercredi 23 septembre 2026 » ou « Wednesday 23 September 2026 ». [EEEE] est le jour de la semaine en toutes lettres, [MMMM] le mois ;
- lignes 80-86 : un identifiant envoyé par le navigateur (dans l'URL ou un champ de formulaire) : un entier strictement positif, ou [null]. L'expression régulière [\d{1,9}] n'accepte que des chiffres, neuf au plus : ni signe, ni espaces, et pas de nombre trop grand pour un [int]. Le contrôleur n'a plus qu'à tester [null] ;
- lignes 93-99 : l'utilisateur décrit par le jeton de la requête, qu'une action reçoit par un paramètre [@AuthenticationPrincipal Jwt jeton]. Un jeton bien signé mais incomplet est traité comme une session expirée ;
- lignes 113-118 : le patient (entité [Client]) de l'utilisateur connecté. Le jeton JWT ne contient que l'identifiant du compte ; on cherche le client rattaché à ce compte. S'il n'existe plus, c'est que le compte a été supprimé depuis la connexion : la [SessionInvalideException] sera traitée par [HtmlExceptionHandler], vu plus haut, qui renvoie à la page de connexion. [orElseThrow] sort le client de son [Optional], ou lève l'exception fabriquée par son argument si l'[Optional] est vide. [SessionInvalideException::new] est une référence de constructeur : l'écriture abrégée de la lambda [() -> new SessionInvalideException()].
Deux sortes de classes transportent les données entre le navigateur, les contrôleurs et les vues : les formulaires, remplis par la liaison de modèle à partir de ce qu'envoie le navigateur, puis validés ; les modèles de vue, préparés par les contrôleurs pour les vues. Les premiers sont des JavaBeans, les seconds des records ; nous verrons pourquoi. Ils sont rangés dans le dossier [models], avec les règles de saisie qu'ils partagent.
11.15.1. models/ReglesSaisie.java
| // =========================================================================
// models/ReglesSaisie.java
// -------------------------------------------------------------------------
// Les règles de saisie communes aux formulaires "personne".
// =========================================================================
package rdvmedecins.models;
import java.util.List;
public final class ReglesSaisie {
private ReglesSaisie() {
}
public static final List<String> TITRES = List.of("Mr", "Mme", "Melle");
// login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
public static final String FORMAT_LOGIN = "^[a-z0-9._-]{1,30}$";
// longueur minimale d'un mot de passe CHOISI par un patient
public static final int MOT_DE_PASSE_MIN = 6;
// " dupont " -> "dupont" ; " " -> null
public static String nettoyer(String valeur) {
return valeur == null || valeur.isBlank() ? null : valeur.trim();
}
// un champ vide -> null ; pour un mot de passe, on ne retire PAS les
// espaces (ils peuvent en faire partie)
public static String videEnNull(String valeur) {
return valeur == null || valeur.isEmpty() ? null : valeur;
}
}
|
- ligne 15 : les titres possibles. Ils servent à fabriquer la liste déroulante des formulaires (cf. [ModelesVues.java], plus bas) ;
- ligne 18 : le format d'un login, décrit par une expression régulière : de 1 à 30 caractères parmi les lettres minuscules, les chiffres, le point, le souligné et le tiret. [^] et [$] désignent le début et la fin du texte ;
- ligne 21 : la longueur minimale d'un mot de passe choisi par un patient ;
- lignes 18 et 21 : ces deux champs sont des constantes : [static final], initialisés par une valeur écrite dans le code. Le compilateur en connaît la valeur, ce qui permet de les utiliser comme arguments d'une annotation, par exemple [@Size(min = ReglesSaisie.MOT_DE_PASSE_MIN)]. Ce n'est pas le cas de la liste [TITRES] (ligne 15), créée à l'exécution ;
- lignes 24-26 : le nettoyage d'une saisie : les espaces de début et de fin sont retirés, et un champ vide ou fait seulement d'espaces ([isBlank()]) devient [null], c'est-à-dire absent ;
- lignes 30-32 : pour un mot de passe, un champ vide devient [null], mais les espaces sont gardés : ils font partie du mot de passe.
Le fichier est présenté en deux extraits.
| // =========================================================================
// models/Formulaires.java
// -------------------------------------------------------------------------
// Les champs des formulaires "personne" et leurs contraintes. Trois
// formulaires partagent les mêmes champs d'identité (titre, prénom, nom) :
//
// ChampsPersonne titre, prénom, nom
// ├─ FormulairePersonne + login / mot de passe FACULTATIFS, version
// │ (l'ADMIN ajoute ou modifie un médecin / client)
// ├─ FormulaireInscription + login / mot de passe / confirmation OBLIGATOIRES
// │ (un patient crée son compte)
// └─ FormulaireMonCompte + login, nouveau mot de passe facultatif,
// mot de passe ACTUEL obligatoire, version
// (un patient modifie son compte)
//
// LIAISON DE MODÈLE : quand une action de contrôleur reçoit un paramètre de
// l'une de ces classes, Spring MVC crée l'objet et remplit chaque champ, par
// son setter, avec le paramètre de même nom (cf. exemple 18).
//
// VALIDATION : avec @Valid, Spring vérifie ensuite les contraintes
// (@NotBlank, @Size...) et range les erreurs dans le BindingResult qui suit
// le paramètre (cf. exemples 09 et 19).
//
// Les messages d'erreur sont des CLÉS de traduction (cf. i18n/*.json).
//
// NETTOYAGE DE LA SAISIE : les setters retirent les espaces superflus
// (une saisie vide devient null) et mettent le login en minuscules. Un mot
// de passe, lui, n'est pas "nettoyé" (les espaces peuvent en faire partie).
//
// Les classes sont "imbriquées" (static) dans la classe Formulaires : un seul
// fichier pour quatre petites classes. On les désigne par
// Formulaires.FormulairePersonne...
// =========================================================================
package rdvmedecins.models;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public final class Formulaires {
private Formulaires() {
}
// ------------------------------------------------------------------ base commune
public abstract static class ChampsPersonne {
@NotNull(message = "ERRORS.INVALID_TITLE")
@Pattern(regexp = "Mr|Mme|Melle", message = "ERRORS.INVALID_TITLE")
private String titre;
// longueurs maximales = celles des colonnes SQL (VARCHAR(20))
@NotBlank(message = "ERRORS.FIRSTNAME_REQUIRED")
@Size(max = 20, message = "ERRORS.FIRSTNAME_TOO_LONG")
private String prenom;
@NotBlank(message = "ERRORS.LASTNAME_REQUIRED")
@Size(max = 20, message = "ERRORS.LASTNAME_TOO_LONG")
private String nom;
public String getTitre() { return titre; }
public void setTitre(String titre) { this.titre = titre; }
public String getPrenom() { return prenom; }
public void setPrenom(String prenom) { this.prenom = ReglesSaisie.nettoyer(prenom); }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = ReglesSaisie.nettoyer(nom); }
}
|
Le commentaire d'en-tête (lignes 4-32) présente les trois formulaires « personne » et leur base commune. Les formulaires suivent la démarche des exemples 18 et 19 : une action reçoit un paramètre annoté [@Valid], suivi d'un paramètre [BindingResult]. [Spring MVC] crée l'objet, le remplit avec les champs reçus (liaison de modèle), puis vérifie les contraintes posées par les annotations (validation) et range les erreurs dans le [BindingResult]. Ces annotations ([@NotNull], [@NotBlank], [@Size], [@Pattern]) sont celles de la norme [Bean Validation] (paquetage [jakarta.validation]), apportée par le starter [spring-boot-starter-validation] avec son implémentation, [Hibernate Validator]. Commentons ce code :
- lignes 41-44 : une classe qui ne sert qu'à regrouper, dans un seul fichier, quatre petites classes imbriquées. Déclarées [static], elles sont indépendantes de toute instance de [Formulaires] : on les désigne par [Formulaires.FormulairePersonne], ou simplement [FormulairePersonne] après un [import] ;
- ligne 47 : les champs d'identité, communs aux trois formulaires. La classe est abstraite ([abstract]) : on ne peut pas en créer d'objet, seulement en dériver ;
- lignes 49-51 : le titre, obligatoire ([@NotNull]) et choisi dans la liste ([@Pattern]). Les valeurs sont répétées dans l'expression régulière [Mr|Mme|Melle] ([|] signifie « ou ») : l'argument d'une annotation doit être une constante, ce que n'est pas la liste [ReglesSaisie.TITRES]. [@Pattern] exige que le texte entier corresponde à l'expression ;
- lignes 53-60 : le prénom et le nom, obligatoires, de 20 caractères au plus : la taille des colonnes SQL (cf. « La base de données »). [@NotBlank] refuse [null], le texte vide et le texte fait d'espaces. Chaque contrainte porte son propre message ; les messages sont des clés de traduction (cf. exemple 22), traduites par la vue dans la langue de l'utilisateur ;
- lignes 62-69 : les accesseurs, écrits sur une seule ligne chacun. Les setters nettoient la saisie au passage : la liaison de modèle affectant les champs par les setters, avant la validation, c'est la valeur nettoyée qui est validée. Un prénom fait d'espaces devient [null], et c'est [@NotBlank] qui le refuse.
Pourquoi des JavaBeans, et non des records ? Un JavaBean est une classe ordinaire qui a un constructeur sans paramètre et, pour chaque propriété, un couple [getXxx()] / [setXxx()]. [Spring MVC] crée l'objet vide, puis appelle un setter pour chaque champ reçu : chaque champ est traité séparément. [Spring MVC] sait aussi remplir un record, mais en appelant son constructeur, avec tous les champs à la fois : si un seul champ ne peut pas être converti (un texte dans le champ numérique [version]), l'objet n'est pas construit, et aucune contrainte n'est vérifiée. Avec un JavaBean, l'erreur de conversion d'un champ n'empêche pas la validation des autres : l'utilisateur voit toutes ses erreurs d'un coup. Le JavaBean permet aussi de nettoyer la saisie dans les setters.
| // ------------------------------------------------------------------ ADMIN
public static class FormulairePersonne extends ChampsPersonne {
// facultatif (cf. metier/DonneesPersonne.java)
@Pattern(regexp = ReglesSaisie.FORMAT_LOGIN, message = "ERRORS.INVALID_LOGIN")
private String login;
// facultatif ; 72 caractères au plus (au-delà, bcrypt ignore la suite)
@Size(max = 72, message = "ERRORS.PASSWORD_TOO_LONG")
private String password;
// champ caché du formulaire de modification (verrou optimiste).
// Integer : un champ absent ou vide donne null ; un texte qui n'est pas
// un nombre donne une erreur de liaison (typeMismatch)
private Integer version;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = minuscules(ReglesSaisie.nettoyer(login)); }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = ReglesSaisie.videEnNull(password); }
public Integer getVersion() { return version; }
public void setVersion(Integer version) { this.version = version; }
}
// ------------------------------------------------------------------ inscription d'un patient
public static class FormulaireInscription extends ChampsPersonne {
@NotNull(message = "ERRORS.LOGIN_REQUIRED")
@Pattern(regexp = ReglesSaisie.FORMAT_LOGIN, message = "ERRORS.INVALID_LOGIN")
private String login;
// deux contraintes @Size, chacune avec son message
@NotNull(message = "ERRORS.PASSWORD_REQUIRED")
@Size(min = ReglesSaisie.MOT_DE_PASSE_MIN, message = "ERRORS.PASSWORD_TOO_SHORT")
@Size(max = 72, message = "ERRORS.PASSWORD_TOO_LONG")
private String password;
@NotNull(message = "ERRORS.CONFIRMATION_REQUIRED")
private String confirmation;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = minuscules(ReglesSaisie.nettoyer(login)); }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = ReglesSaisie.videEnNull(password); }
public String getConfirmation() { return confirmation; }
public void setConfirmation(String confirmation) { this.confirmation = ReglesSaisie.videEnNull(confirmation); }
}
// ------------------------------------------------------------------ "Gérer mon compte"
public static class FormulaireMonCompte extends ChampsPersonne {
@NotNull(message = "ERRORS.LOGIN_REQUIRED")
@Pattern(regexp = ReglesSaisie.FORMAT_LOGIN, message = "ERRORS.INVALID_LOGIN")
private String login;
// facultatif : vide = mot de passe inchangé
@Size(min = ReglesSaisie.MOT_DE_PASSE_MIN, message = "ERRORS.PASSWORD_TOO_SHORT")
@Size(max = 72, message = "ERRORS.PASSWORD_TOO_LONG")
private String password;
private String confirmation;
// le mot de passe ACTUEL, exigé pour toute modification du compte
@NotNull(message = "ERRORS.CURRENT_PASSWORD_REQUIRED")
private String motDePasseActuel;
private Integer version;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = minuscules(ReglesSaisie.nettoyer(login)); }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = ReglesSaisie.videEnNull(password); }
public String getConfirmation() { return confirmation; }
public void setConfirmation(String confirmation) { this.confirmation = ReglesSaisie.videEnNull(confirmation); }
public String getMotDePasseActuel() { return motDePasseActuel; }
public void setMotDePasseActuel(String motDePasse) { this.motDePasseActuel = ReglesSaisie.videEnNull(motDePasse); }
public Integer getVersion() { return version; }
public void setVersion(Integer version) { this.version = version; }
}
private static String minuscules(String texte) {
return texte == null ? null : texte.toLowerCase(java.util.Locale.ROOT);
}
}
|
Commentons ce code :
- ligne 2 : le formulaire de l'ADMIN, pour ajouter ou modifier un médecin ou un client. La classe hérite des champs d'identité, de leurs contraintes et de leurs accesseurs ;
- lignes 4-6 : le login, facultatif ici (non saisi, il vaut à la création le nom en minuscules, et reste inchangé à la modification, cf. « La couche métier »), mais au bon format s'il est présent. Comme toutes les contraintes de [Bean Validation] sauf [@NotNull] et [@NotBlank], [@Pattern] accepte la valeur [null] : c'est ce qui rend le champ facultatif. Le setter (ligne 18) nettoie le login et le met en minuscules (méthode [minuscules], lignes 89-91) ;
- lignes 8-10 : le mot de passe, facultatif lui aussi, de 72 caractères au plus : l'algorithme [bcrypt] ignore les caractères suivants. Il n'est pas nettoyé, seulement ramené à [null] s'il est vide (ligne 21) ;
- lignes 12-15 : la version de la fiche, dans un champ caché du formulaire de modification (verrou optimiste, cf. exemple 28). Le type [Integer], et non [int], accepte la valeur [null] : un champ absent ou vide donne [null]. Un texte qui n'est pas un nombre produit une erreur de conversion, que [web/Validation.java] transforme en message « Le formulaire contient des erreurs » ;
- ligne 28 : la création d'un compte par un patient. Le login est obligatoire (ligne 30) : [@NotNull] suffit, puisque le setter transforme une saisie vide en [null] ;
- lignes 34-38 : le mot de passe, obligatoire, de 6 à 72 caractères. Deux annotations [@Size] sur le même champ : une annotation déclarée répétable peut figurer plusieurs fois, ici pour que chaque limite ait son propre message ;
- lignes 40-41 : la confirmation du mot de passe, obligatoire. L'égalité du mot de passe et de sa confirmation met en jeu deux champs : elle est vérifiée par le contrôleur ([InscriptionController], cf. « La couche web : les contrôleurs ») ;
- ligne 54 : la modification de son compte par un patient. Le login est obligatoire ; le nouveau mot de passe est facultatif (lignes 60-63) : vide, il reste inchangé, et sa confirmation (ligne 65) n'est donc pas exigée ici. Le mot de passe actuel, en revanche, est obligatoire pour toute modification (lignes 67-69), et la version de la fiche voyage dans un champ caché (ligne 71) ;
- lignes 89-91 : la mise en minuscules du login. [Locale.ROOT] rend la conversion indépendante de la langue du serveur (en turc, par exemple, la minuscule de [I] n'est pas [i]). Le nom complet [java.util.Locale] évite une ligne [import].
11.15.3. models/ModelesVues.java
Le fichier est présenté en deux extraits.
| // =========================================================================
// models/ModelesVues.java
// -------------------------------------------------------------------------
// Les "modèles de vue" (view models) : ce que chaque contrôleur transmet à
// sa vue, sous le nom [modele]. Ce sont des records : des objets immuables,
// construits en une fois par le contrôleur, que la vue se contente de lire
// (${modele.agenda.lignes}...).
//
// Règle suivie par toutes les pages : pas de logique dans les vues. Les
// contrôleurs préparent des données DÉJÀ mises en forme ("Mme Marie
// PELISSIER", "8:00 - 8:20", selectionne = true...) et déjà filtrées selon
// le rôle de l'utilisateur ; les vues se contentent de les placer dans le HTML.
// =========================================================================
package rdvmedecins.models;
import java.util.List;
import java.util.Map;
import java.util.Set;
import rdvmedecins.auth.Captcha;
public final class ModelesVues {
private ModelesVues() {
}
// -------------------------------------------------------------------------
// ce que TOUTES les pages ont en commun, lu par le gabarit
// (templates/layout.html) : la clé du titre de la page, et LE message
// d'erreur de la page, affiché en bas de page (null : pas d'erreur)
// -------------------------------------------------------------------------
public interface ModeleVue {
String titrePage();
Message erreur();
}
// un message à traduire : sa clé, et ses paramètres
public record Message(String cle, Map<String, String> parametres) {
public static Message de(String cle) {
return new Message(cle, Map.of());
}
}
// une option d'une liste déroulante (médecin, patient)
public record OptionVue(int id, String libelle, boolean selectionne) {
}
// ------------------------------------------------------------------ connexion
public record LoginModele(String titrePage, Message erreur, String login, Captcha captcha) implements ModeleVue {
}
// ------------------------------------------------------------------ agenda
public record LigneAgendaVue(
int idCreneau,
String horaire, // "8:00 - 8:20"
boolean libre,
String patient, // nom affiché pour un créneau occupé ; null = « Réservé »
boolean moi, // le rendez-vous du patient connecté
boolean reservable, // afficher le bouton "Réserver" ?
String urlReserver,
Integer idRv) { // renseigné seulement si le bouton "Annuler" est affiché
}
public record AgendaVue(String medecin, String jour, List<LigneAgendaVue> lignes) {
}
public record ReservationVue(
int idCreneau,
String horaire,
List<OptionVue> clients, // ADMIN / DOCTOR : les patients parmi lesquels choisir
String patient) { // USER : le patient, c'est lui-même
}
public record AgendaModele(
String titrePage,
Message erreur,
List<OptionVue> medecins,
Integer idMedecin,
String jour, // "yyyy-MM-dd"
AgendaVue agenda,
ReservationVue reservation,
String urlRetour, // l'agenda affiché, sans fenêtre de réservation
// patient qui a déjà un rendez-vous à venir : le message qui explique
// pourquoi il n'y a pas de bouton "Réserver"
String info) implements ModeleVue {
}
|
Les modèles de vue suivent une règle déjà appliquée dans les exemples : pas de logique dans les vues. Les contrôleurs préparent des données déjà mises en forme (« Mme Marie PELISSIER », « 8:00 - 8:20 ») et déjà filtrées selon le rôle de l'utilisateur ; les vues se contentent de les placer dans le HTML. Ce sont des records : construits en une fois par le contrôleur, ils ne sont ensuite que lus par la vue, qui écrit par exemple [${modele.agenda.lignes}] ; [Thymeleaf] appelle alors les accesseurs [agenda()], puis [lignes()]. Chaque contrôleur range le modèle de sa vue sous le nom [modele]. Commentons ce code :
- lignes 32-36 : ce que toutes les pages ont en commun, lu par le gabarit [templates/layout.html] (cf. « Les vues ») : la clé de traduction du titre de la page, et le message d'erreur de la page, affiché en bas, ou [null]. C'est une interface, et non une classe mère : un record ne peut pas dériver d'une classe, mais il peut implémenter des interfaces. Un record qui a des composants [titrePage] et [erreur] possède automatiquement les accesseurs [titrePage()] et [erreur()] : il implémente l'interface sans une ligne de code de plus ;
- lignes 39-44 : un message à traduire : sa clé et ses paramètres. La méthode statique [de] fabrique un message sans paramètre ;
- lignes 47-48 : une option d'une liste déroulante (un médecin, un patient), déjà marquée comme choisie ou non (cf. exemple 18) ;
- lignes 51-52 : la page de connexion : le login déjà saisi et le captcha à afficher. Le corps du record est vide : tout est dans ses composants ;
- lignes 55-64 : une ligne de l'agenda, c'est-à-dire un créneau. Le contrôleur a déjà tout décidé : le nom du patient affiché ou non (un patient ne voit pas le nom des autres : [patient] vaut alors [null], et la vue affiche « Réservé »), le bouton Réserver affiché ou non, le bouton Annuler affiché ou non ([idRv] renseigné). [idRv] est un [Integer], qui peut valoir [null] ;
- lignes 66-67 : l'agenda d'un médecin pour un jour : le médecin, le jour en toutes lettres, et les lignes ;
- lignes 69-74 : la fenêtre de réservation : la liste des patients pour l'ADMIN et les médecins, ou le seul nom du patient connecté pour un patient ;
- lignes 76-88 : la page de l'agenda : la liste des médecins, le médecin et le jour choisis, l'agenda ([null] tant qu'aucun médecin n'est choisi), l'éventuelle fenêtre de réservation, l'adresse de l'agenda sans cette fenêtre (ligne 84), et le message qui explique à un patient pourquoi il ne peut pas réserver.
| // ------------------------------------------------------------------ formulaires "personne"
// les valeurs affichées dans les champs du formulaire (jamais le mot de passe)
public record ValeursPersonne(String titre, String prenom, String nom, String login, Integer version) {
public static ValeursPersonne vide() {
return new ValeursPersonne("Mr", "", "", "", null);
}
}
// l'état d'un formulaire refusé : le message affiché SOUS chaque champ
// (champ -> clé du message), et les champs à encadrer en rouge (classe
// Bootstrap is-invalid) : ceux qui ont un message, plus d'éventuels autres
// (doublon prénom + nom, login pris...)
public record EtatFormulaire(Map<String, String> erreurs, Set<String> champsInvalides) {
public static EtatFormulaire vide() {
return new EtatFormulaire(Map.of(), Set.of());
}
public String erreurDe(String champ) {
return erreurs.get(champ);
}
public String classeInvalide(String champ) {
return champsInvalides.contains(champ) ? "is-invalid" : "";
}
}
// une option de la liste des titres
public record OptionTitre(String valeur, boolean selectionne) {
}
// ce qu'utilise le fragment commun champs-personne.html (titre, prénom, nom)
public interface FormulaireVue extends ModeleVue {
ValeursPersonne valeurs();
EtatFormulaire etat();
// une méthode "par défaut" : écrite dans l'interface, héritée par les records
default List<OptionTitre> titres() {
return ReglesSaisie.TITRES.stream().map(t -> new OptionTitre(t, t.equals(valeurs().titre()))).toList();
}
}
public record FormulairePersonneModele(
String titrePage,
Message erreur,
ValeursPersonne valeurs,
EtatFormulaire etat,
boolean creation, // true : ajout ; false : modification
String action, // URL du POST
String urlListe) implements FormulaireVue {
}
public record InscriptionModele(String titrePage, Message erreur, ValeursPersonne valeurs, EtatFormulaire etat)
implements FormulaireVue {
public int motDePasseMin() {
return ReglesSaisie.MOT_DE_PASSE_MIN;
}
}
public record MonCompteModele(String titrePage, Message erreur, ValeursPersonne valeurs, EtatFormulaire etat)
implements FormulaireVue {
public int motDePasseMin() {
return ReglesSaisie.MOT_DE_PASSE_MIN;
}
}
// ------------------------------------------------------------------ listes médecins / clients
// les clés de traduction propres à chaque type de personne
public record ClesPersonnes(
String titre, // "Médecins"
String ajouter, // "Ajouter un médecin"
String modifier, // "Modifier un médecin"
String vide, // "Aucun médecin."
String confirmerSuppression, // "Supprimer ce médecin et son compte ?"
String ajoute, // "Le médecin {nom} a été ajouté (login : {login})."
String modifie,
String supprime) {
}
public record PersonneVue(String titre, String prenom, String nom, String login, String urlModifier,
String urlSupprimer) {
}
public record ListePersonnesModele(String titrePage, Message erreur, ClesPersonnes cles, String urlAjouter,
List<PersonneVue> personnes) implements ModeleVue {
}
// ------------------------------------------------------------------ espace du patient
public record RendezVousVue(int id, String jour, String horaire, String medecin, String urlAgenda) {
}
public record EspaceModele(String titrePage, Message erreur, String patient, List<RendezVousVue> rendezVous)
implements ModeleVue {
}
// ------------------------------------------------------------------ page d'erreur
public record ErreurModele(String titrePage, Message erreur, int code, String message) implements ModeleVue {
}
}
|
Commentons ce code :
- lignes 4-9 : les valeurs affichées dans les champs d'un formulaire « personne », jamais le mot de passe : un mot de passe n'est pas renvoyé au navigateur. [vide()] fournit les valeurs d'un formulaire d'ajout ;
- lignes 15-28 : l'état d'un formulaire, préparé par [web/Validation.java] : les messages sous les champs, et les champs à encadrer en rouge. Un record peut avoir des méthodes : [erreurDe] rend le message d'un champ (ou [null]), [classeInvalide] la classe [Bootstrap] [is-invalid], qui encadre un champ en rouge, ou une chaîne vide. Les vues écrivent par exemple [th:classappend="${etat.classeInvalide('login')}"], où [etat] désigne [modele.etat] ;
- lignes 31-32 : une option de la liste des titres, celle qui est choisie étant marquée (cf. exemple 18) ;
- lignes 35-44 : ce qu'utilise le fragment commun [champs-personne.html], qui affiche les champs titre, prénom et nom des trois formulaires : les valeurs et l'état. Une interface peut hériter d'une autre ([extends ModeleVue]). Lignes 41-43 : une méthode par défaut ([default]) est une méthode dont le code est écrit dans l'interface elle-même ; les classes et les records qui implémentent l'interface en héritent. Ici, la liste des titres, fabriquée à partir des valeurs du formulaire : le flux des titres est transformé ([map]) en options, et [toList()] rassemble le résultat en liste. Les trois modèles de formulaire en profitent sans rien écrire ;
- lignes 46-54 : le formulaire de l'ADMIN, qui sert à l'ajout et à la modification (cf. exemple 28) : le mode, l'adresse d'envoi du formulaire, et celle de la liste pour le bouton de retour ;
- lignes 56-70 : les formulaires d'inscription et de gestion du compte ; ils transmettent à la vue la longueur minimale d'un mot de passe, qui sert de paramètre [{min}] au message d'erreur du champ et, pour l'inscription, au texte d'aide. Une méthode ordinaire, dans le corps du record, suffit : la vue écrit [${modele.motDePasseMin()}] ;
- lignes 75-84 : les pages de gestion des médecins et des clients sont les mêmes vues ; seuls leurs textes diffèrent. Ce record porte les clés de traduction propres à chaque type de personne ;
- lignes 86-92 : une ligne de la liste des médecins ou des clients, avec les adresses de ses boutons Modifier et Supprimer, et le modèle de la page de la liste ;
- lignes 95-100 : l'espace du patient : son nom et ses rendez-vous à venir, chacun avec un lien vers l'agenda du jour ;
- lignes 103-104 : la page d'erreur : le code HTTP et la clé du message.
11.16. La couche web : les contrôleurs
Les contrôleurs sont la partie visible de la couche web : chacun reçoit les requêtes d'un groupe de pages, lit leurs paramètres, interroge la couche métier (toujours à travers le service [ApplicationModel]), puis choisit une vue et lui passe un modèle, ou bien redirige. Une action renvoie une chaîne de caractères : soit le nom d'une vue (["agenda"] désigne le fichier [templates/agenda.html], cf. exemple 13), soit une redirection (["redirect:/login"], cf. exemple 04). Le modèle de la vue est toujours un seul objet, un [record] du fichier [models/ModelesVues.java], rangé dans le [Model] de Spring sous le nom [modele]. Tous les formulaires POST suivent le schéma Post / Redirect / Get (cf. exemple 20) ; tous les messages sont des clés de traduction, traduites par la vue. Le contrôleur de la connexion, [AuthController], a été commenté avec l'authentification.
11.16.1. controllers/LangueController.java
| // =========================================================================
// controllers/LangueController.java
// -------------------------------------------------------------------------
// GET /langue/fr, GET /langue/en : les deux boutons FR / EN de la barre de
// navigation. Route non protégée : on doit pouvoir changer de langue avant
// de se connecter.
//
// Le choix est mémorisé dans le cookie du CookieLocaleResolver (cf.
// config/WebConfig.java) : à chaque requête suivante, Spring MVC le relit
// et fixe la langue de la requête (le paramètre Locale des actions, la
// langue des messages #{...} des vues). Puis on revient sur la page d'où
// l'on vient.
// =========================================================================
package rdvmedecins.controllers;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.LocaleResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.web.Traducteur;
@Controller
public class LangueController {
private final LocaleResolver localeResolver;
public LangueController(LocaleResolver localeResolver) {
this.localeResolver = localeResolver;
}
@GetMapping("/langue/{langue}")
public String changer(@PathVariable String langue, HttpServletRequest request, HttpServletResponse response) {
if (!Traducteur.LANGUES.contains(langue)) {
// -> page d'erreur 404 (cf. ErreurController)
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
// dépose le cookie de langue
localeResolver.setLocale(request, response, Locale.of(langue));
return "redirect:" + pageDOrigine(request);
}
// -----------------------------------------------------------------------
// la page précédente, d'après l'entête HTTP "Referer" envoyé par le
// navigateur. On n'en garde que le chemin (/agenda?...) : rediriger vers
// une adresse complète fournie par la requête permettrait d'envoyer
// l'utilisateur sur un autre site (faille dite "open redirect").
// -----------------------------------------------------------------------
private static String pageDOrigine(HttpServletRequest request) {
String referer = request.getHeader(HttpHeaders.REFERER);
if (referer == null) {
return "/";
}
try {
URI url = new URI(referer);
String chemin = url.getRawPath();
// "//site.com/..." serait compris par le navigateur comme une adresse
// sur un AUTRE site : refusé aussi
if (!url.isAbsolute() || chemin == null || !chemin.startsWith("/") || chemin.startsWith("//")
|| chemin.startsWith("/langue/")) {
return "/";
}
return url.getRawQuery() == null ? chemin : chemin + "?" + url.getRawQuery();
} catch (URISyntaxException e) {
return "/";
}
}
}
|
C'est, à peu de chose près, le contrôleur de l'exemple 22 : même route [/langue/{langue}], même cookie du [CookieLocaleResolver], même retour à la page d'origine par l'entête [Referer]. Commentons-le :
- ligne 32 : [@Controller] fait de la classe un contrôleur MVC, détecté au démarrage et créé par [Spring] sous la forme d'un bean (un objet dont Spring gère la création et qu'il peut injecter ailleurs, cf. exemple 06) ;
- lignes 35-39 : le contrôleur reçoit par injection de dépendances le [LocaleResolver] défini dans [config/WebConfig.java]. La classe n'a qu'un constructeur : Spring l'appelle en lui fournissant les beans demandés, sans qu'il soit besoin de l'annoter [@Autowired]. Tous les contrôleurs de l'application sont écrits ainsi ;
- lignes 41-42 : la route [GET /langue/{langue}]. [@PathVariable] lie le paramètre [langue] au segment [{langue}] de l'URL (cf. exemple 02). L'action reçoit aussi la requête et la réponse du serveur ([Servlet]) : il suffit de les déclarer en paramètres pour que Spring MVC les fournisse ;
- lignes 43-46 : une langue inconnue ([/langue/de]) donne une page 404. L'action ne fabrique pas cette page : elle lève une [ResponseStatusException], que Spring MVC transforme en réponse d'erreur 404 ; le serveur transmet alors la requête à [/error], où [ErreurController] (plus bas) fabrique la page ;
- ligne 48 : [setLocale] dépose le cookie de la langue (son nom, [rdvmedecins_langue], sa durée de vie d'un an et ses options [HttpOnly] et [SameSite=Lax] ont été fixés dans [WebConfig]). À chaque requête suivante, le [CookieLocaleResolver] relit ce cookie et fixe la langue de la requête ;
- ligne 49 : retour à la page d'où l'on vient. Le préfixe [redirect:] demande à Spring MVC une réponse 302 ;
- lignes 58-76 : la page précédente, d'après l'entête [Referer]. On n'en garde que le chemin et les paramètres ([/agenda?idMedecin=1&jour=...]) : rediriger vers une adresse complète fournie par la requête permettrait d'envoyer l'utilisateur sur un autre site (faille dite « open redirect ») ;
- lignes 68-71 : sont refusés, avec retour à la racine du site : une adresse relative, un chemin qui commence par [//] (le navigateur le comprendrait comme l'adresse d'un autre site) et une page [/langue/...], qui ferait tourner le navigateur en rond. [getRawPath] et [getRawQuery] rendent le chemin et les paramètres tels qu'ils figuraient dans l'URL, encore encodés ([%20]...), prêts à être replacés dans une adresse.
Vérifions-le avec [curl] :
| curl -i -H "Referer: http://localhost:8081/login" http://localhost:8081/langue/en
|
| HTTP/1.1 302
Set-Cookie: rdvmedecins_langue=en; Path=/; Max-Age=31536000; Expires=Sat, 25 Sep 2027 16:47:10 GMT; HttpOnly; SameSite=Lax
Location: http://localhost:8081/login
Content-Length: 0
|
Le cookie est déposé, et le navigateur est renvoyé sur la page de connexion, qui s'affichera désormais en anglais.
11.16.2. controllers/AgendaController.java
La page principale de l'application, et le contrôleur le plus riche. Commentons-le par parties.
11.16.2.1. Les URL de la page
| // =========================================================================
// controllers/AgendaController.java
// -------------------------------------------------------------------------
// La page de l'agenda : choix d'un médecin et d'un jour, agenda de ce
// médecin ce jour-là, réservation et annulation de rendez-vous.
//
// GET /agenda?idMedecin=1&jour=2026-09-23 la page
// GET /agenda?idMedecin=1&jour=2026-09-23&reserver=7 la même page, avec
// la fenêtre de réservation
// du créneau 7 ouverte
// POST /agenda/reserver crée un rendez-vous
// POST /agenda/annuler annule un rendez-vous
//
// Toute la page est décrite par son URL : on peut la recharger, la mettre
// en favori, utiliser le bouton "Précédent" du navigateur - sans une ligne
// de JavaScript.
//
// Ce que chacun voit et peut faire :
//
// | rôle | créneau occupé affiché | Réserver | Annuler |
// |---------------|-----------------------------------|--------------------|---------|
// | ADMIN, DOCTOR | nom du patient | pour un patient | oui |
// | | | choisi dans liste | |
// | USER (patient)| « Réservé », ou SON nom si c'est | pour lui-même, s'il| non (*) |
// | | son propre rendez-vous | n'a pas déjà un rdv| |
// | | | à venir | |
//
// (*) le patient annule son rendez-vous depuis sa page "Mon espace".
//
// CONFIDENTIALITÉ : pour un patient, le nom des AUTRES patients n'est pas
// simplement masqué par la vue - il n'est jamais placé dans le modèle
// transmis à la vue. Le HTML envoyé au navigateur ne le contient donc pas,
// même caché (un simple "Afficher le code source" le révélerait sinon).
//
// Pas de réservation dans le passé : le bouton "Réserver" n'apparaît que
// pour aujourd'hui et les jours suivants (et la couche métier le refuserait
// de toute façon).
//
// Sécurité : toutes les URL /agenda exigent un utilisateur authentifié
// (cf. SecuriteConfig : anyRequest().authenticated()). @PreAuthorize sur une
// action exige en plus l'un des rôles cités. Sans jeton valide : retour à
// /login ; rôle insuffisant : page 403.
// =========================================================================
package rdvmedecins.controllers;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest;
import rdvmedecins.auth.UtilisateurConnecte;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Personne;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.Dates;
import rdvmedecins.metier.MetierException;
import rdvmedecins.models.ModelesVues.AgendaModele;
import rdvmedecins.models.ModelesVues.AgendaVue;
import rdvmedecins.models.ModelesVues.LigneAgendaVue;
import rdvmedecins.models.ModelesVues.Message;
import rdvmedecins.models.ModelesVues.OptionVue;
import rdvmedecins.models.ModelesVues.ReservationVue;
import rdvmedecins.web.ApplicationModel;
import rdvmedecins.web.MessageFlash;
import rdvmedecins.web.Outils;
@Controller
@RequestMapping("/agenda")
public class AgendaController {
// ce que le contrôleur sait de l'utilisateur, pour décider de l'affichage
private record Droits(
boolean gestionnaire, // ADMIN ou DOCTOR
Client patient, // le client connecté (rôle USER), sinon null
boolean patientPeutReserver) { // USER sans rendez-vous à venir
}
// les champs cachés idMedecin / jour, communs aux deux formulaires POST
private record AgendaDemande(int idMedecin, LocalDate jour) {
}
private final ApplicationModel application;
public AgendaController(ApplicationModel application) {
this.application = application;
}
|
- lignes 7-12 : les routes de la page. On remarque que la fenêtre de réservation n'est pas ouverte par du [JavaScript] : c'est un paramètre de l'URL ([reserver=7]) qui demande au serveur de fabriquer la page avec la fenêtre ouverte. Le bouton Réserver est donc un simple lien. Toute la page est décrite par son URL : on peut la recharger, la mettre en favori, utiliser le bouton « Précédent » du navigateur ;
- lignes 20-28 : ce que chaque rôle voit et peut faire. Ce tableau est la « spécification » de la page : tout le reste du contrôleur le met en œuvre ;
- lignes 30-33 : la règle de confidentialité, sur laquelle nous reviendrons : le nom des autres patients n'est pas caché par la vue, il n'est jamais envoyé au navigateur ;
- lignes 39-42 : la sécurité. La classe ne porte aucune annotation de sécurité : c'est la configuration de [Spring Security] ([anyRequest().authenticated()], cf. [config/SecuriteConfig.java]) qui exige un utilisateur connecté pour toute adresse [/agenda...]. Les actions qui exigent en plus un rôle portent l'annotation [@PreAuthorize] (cf. exemple 26) ;
- lignes 84-86 : [@RequestMapping("/agenda")], posé sur la classe, est le préfixe de toutes les routes du contrôleur (cf. exemple 02) ;
- lignes 88-93 : ce que le contrôleur sait de l'utilisateur connecté, calculé une fois par requête (cf. la méthode [droits], plus bas). C'est un record (cf. « Le domaine : l'agenda »), dont les accesseurs sont [droits.gestionnaire()], [droits.patient()]... Ce [record] est privé et déclaré à l'intérieur de la classe : il ne sert qu'à elle. Un gestionnaire est un ADMIN ou un DOCTOR ; [patient] n'est renseigné que pour un client (rôle USER) ;
- lignes 95-97 : le médecin et le jour lus dans les champs cachés des deux formulaires POST de la page, une fois convertis ;
- lignes 99-103 : le service [ApplicationModel] (cf. « La couche web : les services communs »), reçu par le constructeur.
11.16.2.2. GET /agenda
| // -----------------------------------------------------------------------
// GET /agenda
// Les paramètres de l'URL sont lus bruts (request.getParameter), pour
// distinguer un paramètre ABSENT (null) d'un paramètre VIDE (?idMedecin=).
// -----------------------------------------------------------------------
@GetMapping
public String afficher(@AuthenticationPrincipal Jwt jeton, HttpServletRequest request,
Locale locale, Model model) {
String erreur = null;
UtilisateurConnecte utilisateur = Outils.utilisateur(jeton);
Droits droits = droits(utilisateur);
// le jour : celui de l'URL s'il est valide, sinon aujourd'hui
LocalDate jour = Dates.aujourdhui();
String jourBrut = request.getParameter("jour");
if (jourBrut != null) {
Optional<LocalDate> jourDemande = Outils.dateValide(jourBrut);
if (jourDemande.isPresent()) {
jour = jourDemande.get();
} else {
erreur = "ERRORS.INVALID_DATE";
}
}
boolean jourReservable = !jour.isBefore(Dates.aujourdhui());
// le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
String idMedecinBrut = request.getParameter("idMedecin");
Integer idMedecin = Outils.entierPositif(idMedecinBrut);
if (idMedecinBrut == null && utilisateur.role() == Role.DOCTOR) {
idMedecin = application.getMedecinByUserId(utilisateur.userId()).map(m -> m.getId()).orElse(null);
}
// la liste déroulante des médecins ("final" : utilisable dans le lambda)
final Integer idChoisi = idMedecin;
List<OptionVue> medecins = application.getAllMedecins().stream()
.map(m -> option(m, m.getId().equals(idChoisi)))
.toList();
// l'agenda, si un médecin est choisi (et la date valide)
AgendaVue agenda = null;
if (idMedecin != null && erreur == null) {
Optional<AgendaMedecinJour> agendaMedecin = application.getAgendaMedecinJour(idMedecin, jour);
if (agendaMedecin.isEmpty()) {
erreur = "ERRORS.DOCTOR_NOT_FOUND";
} else {
agenda = agendaVue(agendaMedecin.get(), droits, jourReservable, locale);
}
}
|
- ligne 7 : [@GetMapping] sans chemin : l'action répond à [GET /agenda], le préfixe du contrôleur ;
- lignes 8-9 : les paramètres de l'action, tous fournis par Spring MVC. [@AuthenticationPrincipal Jwt jeton] est le jeton [JWT] de l'utilisateur connecté, déjà vérifié par [Spring Security] (cf. « L'authentification ») ; [request] donne accès aux paramètres bruts de l'URL ; [locale] est la langue de la requête, fixée par le [CookieLocaleResolver] ; [model] est le modèle, que l'action remplit pour la vue. L'action ne déclare pas les paramètres [idMedecin] ou [jour] : elle les lit elle-même dans la requête ([HttpServletRequest], cf. exemple 05) : [request.getParameter] rend [null] pour un paramètre absent et une chaîne vide pour un paramètre vide ([?idMedecin=]). Ces deux cas doivent être distingués (voir plus bas) ;
- lignes 11-12 : l'utilisateur connecté et ses droits. [Outils.utilisateur] (dossier [web]) lit les informations du jeton (les claims : identifiant, login, nom, rôle) et en fait un objet [UtilisateurConnecte] ;
- lignes 15-25 : le jour demandé, s'il est valide ; aujourd'hui sinon. Un paramètre [jour] absent donne aujourd'hui, sans message. Présent mais invalide ([jour=2026-02-30]), il produit un message d'erreur. [Outils.dateValide] rend un [Optional] (cf. « La couche DAO »), que l'on teste par [isPresent()] avant d'en extraire la valeur par [get()]. On ne peut réserver qu'à partir d'aujourd'hui ([jourReservable]) ;
- lignes 28-32 : le médecin demandé. [Outils.entierPositif] rend un entier strictement positif, ou [null] pour une valeur absente, vide ou invalide : son type est [Integer], la version « objet » du type [int], qui peut valoir [null]. Si le paramètre [idMedecin] est absent de l'URL, un médecin connecté voit son propre agenda : c'est sa page d'accueil. S'il est vide ([idMedecin=], l'option « choisir un médecin » de la liste), aucun agenda n'est affiché. Ligne 31, [map(m -> m.getId())] transforme l'[Optional<Medecin>] rendu par [getMedecinByUserId] en un [Optional<Integer>], et [orElse(null)] en extrait la valeur, ou [null] s'il est vide ;
- lignes 35-38 : les options de la liste déroulante des médecins, lue dans le cache d'[ApplicationModel] ; celle du médecin demandé est sélectionnée (cf. la méthode [option], en fin de fichier). [stream()] fait de la liste un flux, que [map] transforme élément par élément, et que [toList()] ramène à une liste. [m -> option(...)] est une lambda : une fonction anonyme, écrite sur place. Une lambda ne peut utiliser une variable locale que si celle-ci n'est plus jamais modifiée : c'est pourquoi [idMedecin], modifiée ligne 31, est recopiée ligne 35 dans une variable [final] (non modifiable). Ligne 37, les deux [Integer] sont comparés par [equals], qui compare leurs valeurs, et non par [==], qui comparerait deux objets : un piège classique de [Java] ;
- lignes 41-49 : l'agenda n'est calculé que si un médecin est choisi et si la date est valide. Un identifiant de médecin inconnu donne un message, pas une page d'erreur. L'agenda calculé par la couche métier (un objet [AgendaMedecinJour] du dossier [domain]) est mis en forme par la méthode [agendaVue], commentée plus bas.
11.16.2.3. La fenêtre de réservation et le modèle de la vue
| // la fenêtre de réservation : seulement si le créneau demandé est
// réservable par cet utilisateur
ReservationVue reservation = null;
Integer idCreneau = Outils.entierPositif(request.getParameter("reserver"));
if (agenda != null && idCreneau != null) {
Optional<LigneAgendaVue> ligne = agenda.lignes().stream()
.filter(l -> l.idCreneau() == idCreneau && l.reservable())
.findFirst();
if (ligne.isPresent()) {
reservation = new ReservationVue(
ligne.get().idCreneau(),
ligne.get().horaire(),
droits.gestionnaire()
? application.getAllClients().stream().map(c -> option(c, false)).toList()
: List.of(),
droits.patient() != null ? Outils.libelle(droits.patient()) : null);
}
}
model.addAttribute("modele", new AgendaModele(
"AGENDA.PAGE_TITLE",
erreur == null ? null : Message.de(erreur),
medecins,
idMedecin,
Outils.iso(jour),
agenda,
reservation,
idMedecin != null ? urlAgenda(idMedecin, jour, null) : "/agenda",
infoPatient(droits, agenda)));
return "agenda";
}
|
- lignes 3-8 : le créneau dont l'URL demande la réservation ([reserver=7]). Il doit figurer dans l'agenda affiché et être réservable par cet utilisateur : une URL fabriquée à la main (un créneau passé, occupé, ou d'un autre médecin) n'ouvre pas la fenêtre. [filter] ne garde que les lignes qui vérifient la condition, et [findFirst] rend la première, dans un [Optional] (vide si aucune ne convient) ;
- lignes 9-17 : le modèle de la fenêtre. Un gestionnaire choisit le patient dans une liste (tous les clients, lus dans le cache) ; un patient réserve pour lui-même : la fenêtre affiche simplement son nom. [List.of()] est une liste vide ;
- lignes 20-29 : le modèle de la page, un [AgendaModele] (cf. [models/ModelesVues.java]), rangé dans le modèle sous le nom [modele]. Les arguments du constructeur d'un [record] sont donnés dans l'ordre de ses champs : la clé du titre de la page, l'erreur éventuelle (un [Message] : une clé de traduction et ses paramètres), les médecins, le médecin choisi, le jour au format [aaaa-mm-jj], l'agenda, la fenêtre de réservation, etc. [condition ? valeur1 : valeur2] vaut [valeur1] si la condition est vraie, [valeur2] sinon ;
- ligne 28 : l'adresse de l'agenda affiché, sans la fenêtre de réservation : c'est celle du bouton qui ferme la fenêtre ;
- ligne 29 : un message d'information pour le patient qui a déjà un rendez-vous à venir : il lui explique pourquoi il ne voit aucun bouton Réserver. Il est calculé par la méthode [infoPatient], en fin de fichier ;
- ligne 30 : le nom de la vue : [templates/agenda.html].
11.16.2.4. POST /agenda/reserver
| // -----------------------------------------------------------------------
// POST /agenda/reserver champs : idMedecin, jour, idCreneau, idClient
//
// Pour un patient (USER), le champ idClient est IGNORÉ : il réserve
// toujours pour lui-même. Faire confiance à un champ envoyé par le
// navigateur permettrait à un patient malin de réserver au nom d'un autre.
//
// Schéma "Post / Redirect / Get" : quel que soit le résultat, on ne
// fabrique pas de page ici - on dépose un message flash et on redirige
// vers l'agenda (cf. exemple 20).
// -----------------------------------------------------------------------
@PostMapping("/reserver")
@PreAuthorize("hasAnyRole('ADMIN', 'DOCTOR', 'USER')")
public String reserver(
@RequestParam(required = false) String idMedecin,
@RequestParam(required = false) String jour,
@RequestParam(required = false) String idCreneau,
@RequestParam(required = false) String idClient,
@AuthenticationPrincipal Jwt jeton,
RedirectAttributes redirection) {
AgendaDemande demande = lireAgendaDemande(idMedecin, jour);
Integer idCre = Outils.entierPositif(idCreneau);
if (demande == null || idCre == null) {
// -> page "requête invalide" (cf. ErreurController)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
UtilisateurConnecte utilisateur = Outils.utilisateur(jeton);
Optional<Client> client;
if (utilisateur.estPatient()) {
client = Optional.of(Outils.patientConnecte(application, utilisateur));
} else {
Integer idCli = Outils.entierPositif(idClient);
if (idCli == null) {
// aucun patient choisi : on rouvre la fenêtre de réservation
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.CHOOSE_PATIENT"));
return "redirect:" + urlAgenda(demande.idMedecin(), demande.jour(), idCre);
}
client = application.getClientById(idCli);
}
Optional<Creneau> creneau = application.getCreneauById(idCre);
MessageFlash flash;
if (client.isEmpty()) {
flash = MessageFlash.erreur("ERRORS.PATIENT_NOT_FOUND");
} else if (creneau.isEmpty() || creneau.get().getIdMedecin() != demande.idMedecin()) {
flash = MessageFlash.erreur("ERRORS.SLOT_NOT_FOUND");
} else {
try {
// les règles (pas dans le passé, un seul rendez-vous à venir par
// patient, créneau libre) sont vérifiées par la couche métier
application.ajouterRv(demande.jour(), creneau.get(), client.get());
flash = MessageFlash.succes("AGENDA.BOOKED");
} catch (MetierException e) {
// même règle, mais un message à la 2e personne quand c'est le
// patient lui-même qui réserve (« Vous avez déjà... » plutôt
// que « Ce patient... »). Les autres exceptions (vraies
// anomalies) ne sont pas attrapées : page 500.
flash = e.getCle().equals("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT") && utilisateur.estPatient()
? MessageFlash.erreur("AGENDA.ALREADY_BOOKED_INFO")
: MessageFlash.erreur(e.getCle(), e.getParametres());
}
}
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, flash);
return "redirect:" + urlAgenda(demande.idMedecin(), demande.jour(), null);
}
|
- ligne 13 : [@PreAuthorize] exige, en plus d'un utilisateur connecté, l'un des rôles cités (cf. exemple 26). L'annotation contient une expression, évaluée par [Spring Security] avant l'appel de la méthode : [hasAnyRole('ADMIN', 'DOCTOR', 'USER')] est vraie si l'utilisateur a l'un de ces rôles. Ici, elle ne restreint rien de plus ; elle documente la règle, et la protégerait si un quatrième rôle apparaissait ;
- lignes 15-18 : les champs du formulaire de la fenêtre de réservation (cf. exemple 11). [@RequestParam] lie chaque paramètre de la méthode au champ de même nom ; [required = false] : un champ absent donne [null] au lieu d'une erreur 400 de Spring MVC. Ils sont reçus sous forme de chaînes, et convertis ensuite avec prudence ;
- ligne 20 : [RedirectAttributes] sert à déposer un message flash avant une redirection (cf. exemple 20) ;
- lignes 21-26 : le médecin et le jour sont des champs cachés du formulaire : ils permettent de revenir à l'agenda affiché. S'ils sont invalides, la requête ne vient pas de notre page : elle a été fabriquée à la main. L'exception [ResponseStatusException(HttpStatus.BAD_REQUEST)] produit une réponse 400, que le serveur transforme en page d'erreur (cf. [ErreurController]) ;
- lignes 29-31 : pour un patient, le champ [idClient] est ignoré : il réserve toujours pour lui-même. Se fier à ce champ, envoyé par le navigateur, permettrait à un patient malin de réserver au nom d'un autre. [Outils.patientConnecte] retrouve le client à partir du jeton ; [Optional.of] le place dans un [Optional], pour qu'il ait le même type que celui de la ligne 39 ;
- lignes 33-38 : un gestionnaire qui n'a pas choisi de patient retrouve la fenêtre de réservation (l'URL avec [reserver]), avec un message flash d'erreur. [addFlashAttribute] range le message dans les attributs flash, que [web/CookieFlashMapManager.java] transporte dans un cookie jusqu'à la requête suivante ;
- lignes 42-47 : le patient doit exister, et le créneau doit exister et appartenir au médecin de l'agenda : on ne peut pas réserver chez un médecin un créneau d'un autre ;
- ligne 52 : les règles de gestion (pas dans le passé, créneau libre, un seul rendez-vous à venir par patient) sont vérifiées par la couche métier, qui lève une [MetierException] en cas de refus ;
- lignes 54-62 : toute [MetierException] devient un message flash d'erreur, avec sa clé et ses paramètres. Pour un patient, le message « ce patient a déjà un rendez-vous à venir » est remplacé par un message à la deuxième personne. Les autres exceptions ne sont pas attrapées : ce sont de vraies anomalies, qui aboutissent à la page d'erreur 500. ([C#] dispose pour ce cas d'un filtre d'exception, [catch ... when], qui n'existe pas en [Java] : on teste la condition dans le bloc [catch]) ;
- lignes 64-65 : le schéma PRG : quel que soit le résultat, message flash et redirection vers l'agenda, qui affichera le message.
11.16.2.5. POST /agenda/annuler
| // -----------------------------------------------------------------------
// POST /agenda/annuler champs : idRv, idMedecin, jour
// (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
// -----------------------------------------------------------------------
@PostMapping("/annuler")
@PreAuthorize("hasAnyRole('ADMIN', 'DOCTOR')")
public String annuler(
@RequestParam(required = false) String idRv,
@RequestParam(required = false) String idMedecin,
@RequestParam(required = false) String jour,
RedirectAttributes redirection) {
AgendaDemande demande = lireAgendaDemande(idMedecin, jour);
Integer id = Outils.entierPositif(idRv);
if (demande == null || id == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
Optional<Rv> rv = application.getRvById(id);
if (rv.isEmpty()) {
// déjà annulé (par quelqu'un d'autre, ou double clic)
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.APPOINTMENT_NOT_FOUND"));
} else {
application.supprimerRv(rv.get());
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.succes("AGENDA.CANCELLED"));
}
return "redirect:" + urlAgenda(demande.idMedecin(), demande.jour(), null);
}
|
- lignes 5-6 : l'annulation est réservée aux gestionnaires ; un patient annule son rendez-vous depuis « Mon espace » (cf. [EspacePatientController]). Un patient qui enverrait cette requête recevrait la page 403 : [Spring Security] refuse l'appel de la méthode, et son gestionnaire de refus (cf. [SecuriteConfig]) répond par une erreur 403, que le serveur transmet à [/error] ;
- lignes 12-16 : les mêmes contrôles que pour la réservation ;
- lignes 17-20 : un rendez-vous déjà annulé (double clic, ou annulation par un autre gestionnaire) donne un message d'erreur, pas une page d'erreur ;
- lignes 21-25 : la suppression, puis le schéma PRG.
Avec le cookie du jeton d'un patient connecté (celui de Mr Jean MARTIN, que le lecteur recopiera depuis son navigateur, cf. « L'authentification » ; il est abrégé ici en [...]), la requête est refusée :
| curl -i -b "rdvmedecins_jwt=..." -X POST http://localhost:8081/agenda/annuler -d "idRv=1&idMedecin=1&jour=2026-09-25"
|
| HTTP/1.1 403
Content-Type: text/html;charset=UTF-8
<!doctype html>
...
<h1 class="display-4 text-danger">403</h1>
<p class="lead">Votre rôle ne vous permet pas d'accéder à cette page.</p>
...
|
| // -----------------------------------------------------------------------
// qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
// -----------------------------------------------------------------------
private Droits droits(UtilisateurConnecte utilisateur) {
if (!utilisateur.estPatient()) {
return new Droits(true, null, false);
}
Client patient = Outils.patientConnecte(application, utilisateur);
List<Rv> rvAVenir = application.getRvAVenirClient(patient.getId());
return new Droits(false, patient, rvAVenir.isEmpty());
}
// -----------------------------------------------------------------------
// l'agenda du médecin, mis en forme pour la vue - et filtré selon les droits
// -----------------------------------------------------------------------
private AgendaVue agendaVue(AgendaMedecinJour agendaMedecin, Droits droits, boolean jourReservable,
Locale locale) {
int idMedecin = agendaMedecin.medecin().getId();
LocalDate jour = agendaMedecin.jour();
List<LigneAgendaVue> lignes = agendaMedecin.creneauxMedecinJour().stream().map(cmj -> {
Creneau creneau = cmj.creneau();
Rv rv = cmj.rv();
boolean libre = rv == null;
// le rendez-vous du patient connecté ?
boolean moi = rv != null && droits.patient() != null && rv.getIdClient().equals(droits.patient().getId());
return new LigneAgendaVue(
creneau.getId(),
Outils.horaire(creneau),
libre,
// LE point de confidentialité : un patient ne reçoit le nom que
// de SON rendez-vous ; pour les autres, null (la vue affiche « Réservé »)
rv != null && (droits.gestionnaire() || moi) ? Outils.libelle(rv.getClient()) : null,
moi,
libre && jourReservable && (droits.gestionnaire() || droits.patientPeutReserver()),
urlAgenda(idMedecin, jour, creneau.getId()),
rv != null && droits.gestionnaire() ? rv.getId() : null);
}).toList();
return new AgendaVue(Outils.libelle(agendaMedecin.medecin()), Outils.formaterJour(jour, locale), lignes);
}
// ----------------------------------------------------------------- fonctions utilitaires
// les champs cachés idMedecin / jour des formulaires POST ; null s'ils sont invalides
private static AgendaDemande lireAgendaDemande(String idMedecinBrut, String jourBrut) {
Integer idMedecin = Outils.entierPositif(idMedecinBrut);
Optional<LocalDate> jour = Outils.dateValide(jourBrut);
return idMedecin == null || jour.isEmpty() ? null : new AgendaDemande(idMedecin, jour.get());
}
private static String urlAgenda(int idMedecin, LocalDate jour, Integer reserver) {
String url = "/agenda?idMedecin=" + idMedecin + "&jour=" + Outils.iso(jour);
return reserver == null ? url : url + "&reserver=" + reserver;
}
private static OptionVue option(Personne p, boolean selectionne) {
return new OptionVue(p.getId(), Outils.libelle(p), selectionne);
}
// -----------------------------------------------------------------------
// le message d'information d'un patient qui a déjà un rendez-vous à venir
// (pourquoi il n'y a pas de bouton "Réserver"), ou null. Inutile si SON
// rendez-vous figure dans l'agenda affiché (en bleu) : c'est le cas juste
// après qu'il vient de le prendre, où le message serait déplacé.
// -----------------------------------------------------------------------
private static String infoPatient(Droits droits, AgendaVue agenda) {
if (droits.patient() == null || droits.patientPeutReserver()) {
return null;
}
boolean rvAffiche = agenda != null && agenda.lignes().stream().anyMatch(LigneAgendaVue::moi);
return rvAffiche ? null : "AGENDA.ALREADY_BOOKED_INFO";
}
}
|
- lignes 4-11 : un gestionnaire a tous les droits ; un patient peut réserver s'il n'a pas de rendez-vous à venir ;
- lignes 16-17 : l'agenda calculé par la couche métier est transformé en lignes de tableau, une par créneau ([LigneAgendaVue]) ;
- ligne 20 : la lambda passée à [map] s'étend ici sur plusieurs lignes : son corps est un bloc entre accolades, terminé par un [return] ;
- ligne 25 : [moi] dit si le rendez-vous est celui du patient connecté ;
- ligne 28 : la vue ne reçoit que des données déjà mises en forme ([8:00 - 8:20]) : elle n'a plus rien à calculer ;
- ligne 32 : le point de confidentialité de l'application. Le nom du patient n'est placé dans le modèle que pour un gestionnaire, ou pour le patient dont c'est le rendez-vous. Pour les autres, le modèle contient [null], et la vue affiche « Réservé ». Le nom n'est pas simplement caché par la vue : il n'est jamais envoyé au navigateur, et « Afficher le code source de la page » ne le révélerait pas ;
- lignes 34-36 : ce que la vue doit afficher, décidé par le contrôleur : le bouton Réserver (créneau libre, jour non passé, et utilisateur autorisé à réserver), son lien (l'URL de la page, avec [reserver]), et le bouton Annuler ([idRv] n'est renseigné que pour un gestionnaire). La vue ne décide de rien : elle suit ces indications ;
- ligne 38 : l'agenda complet : le médecin, le jour, formaté dans la langue de la requête (« vendredi 25 septembre 2026 »), les lignes ;
- lignes 44-48 : la lecture des deux champs cachés communs aux formulaires POST : un [AgendaDemande], ou [null] s'ils sont invalides ;
- lignes 50-53 : l'URL de l'agenda d'un médecin un jour donné, éventuellement avec la fenêtre de réservation ouverte. Toutes les redirections du contrôleur la fabriquent par cette méthode : les URL de la page sont écrites à un seul endroit ;
- lignes 55-57 : une option de liste déroulante (identifiant, libellé, sélection), pour un médecin comme pour un client : le paramètre est de type [Personne], la classe mère des deux entités ;
- lignes 59-71 : le message d'information du patient qui a déjà un rendez-vous à venir. Il n'est pas envoyé si ce rendez-vous figure dans l'agenda affiché : son nom en bleu suffit, et c'est justement la page qu'il voit juste après avoir réservé, où le message « vous avez déjà un rendez-vous à venir » serait déplacé. Ligne 69, [anyMatch] dit si au moins une ligne du flux vérifie la condition ; [LigneAgendaVue::moi] est une référence de méthode : l'équivalent de la lambda [l -> l.moi()].
Voici ce que reçoit le navigateur de Mr Jean MARTIN (rôle USER) pour l'agenda de Mme PELISSIER, dont deux créneaux sont réservés (extrait) :
| curl -b "rdvmedecins_jwt=..." "http://localhost:8081/agenda?idMedecin=1&jour=2026-09-26"
|
| <div class="card p-3">
<h5>Agenda du samedi 26 septembre 2026 - Mme Marie PELISSIER</h5>
...
<tr>
<td>8:20 - 8:40</td>
<td>
<span class="badge text-bg-secondary">Réservé</span>
</td>
<td>
</td>
</tr>
...
<tr>
<td>9:00 - 9:20</td>
<td>
<span class="badge text-bg-primary">Mr Jean MARTIN</span>
</td>
<td>
</td>
</tr>
|
Le créneau de 8h20 est réservé par une autre patiente : son nom ne figure nulle part dans la page. Celui de 9h00 est le rendez-vous de Mr MARTIN : son nom apparaît, en bleu. Comme il a déjà un rendez-vous à venir, aucun bouton Réserver n'est affiché. Le message d'information (« Vous avez déjà un rendez-vous à venir... ») n'apparaît pas ici, puisque ce rendez-vous est sous ses yeux ; il apparaît sur l'agenda d'un autre jour ou d'un autre médecin, pour expliquer l'absence de boutons.
11.16.3. controllers/PersonnesControllerBase.java
| // =========================================================================
// controllers/PersonnesControllerBase.java
// -------------------------------------------------------------------------
// La gestion des médecins et celle des clients (réservées à l'ADMIN) sont
// IDENTIQUES : même liste, même formulaire, mêmes règles. Plutôt que
// d'écrire deux fois les mêmes actions, on les écrit une fois dans cette
// classe ABSTRAITE et GÉNÉRIQUE, dont héritent MedecinsController
// (@RequestMapping("/medecins")) et ClientsController
// (@RequestMapping("/clients")). Chaque sous-classe n'a qu'à fournir sa
// "configuration" : quelles méthodes appeler, quels libellés afficher.
//
// Les routes posées ICI (@GetMapping("/ajouter")...) sont héritées, et
// complètent la route de la sous-classe. On obtient donc, pour chacune :
//
// GET /medecins liste
// GET /medecins/ajouter formulaire vide
// POST /medecins/ajouter création
// GET /medecins/{id}/modifier formulaire pré-rempli
// POST /medecins/{id}/modifier modification
// POST /medecins/{id}/supprimer suppression
//
// (et les mêmes URL sous /clients). Les vues sont elles aussi communes :
// templates/personnes/liste.html et templates/personnes/formulaire.html.
//
// Une classe abstraite n'est jamais un bean : seules ses sous-classes
// concrètes, annotées @Controller, sont des contrôleurs.
//
// SÉCURITÉ : @PreAuthorize("hasRole('ADMIN')") est posé sur CETTE classe,
// et non sur les sous-classes. Pour décider quelle règle s'applique à une
// action, Spring Security regarde l'annotation de la méthode, puis celle de
// la classe qui DÉCLARE la méthode - ici, PersonnesControllerBase. Posée
// seulement sur MedecinsController, l'annotation serait ignorée pour les
// actions héritées : un USER pourrait ouvrir /medecins !
// =========================================================================
package rdvmedecins.controllers;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.IntConsumer;
import java.util.function.Supplier;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.validation.Valid;
import rdvmedecins.entities.Personne;
import rdvmedecins.metier.DonneesPersonne;
import rdvmedecins.metier.MetierException;
import rdvmedecins.models.Formulaires.FormulairePersonne;
import rdvmedecins.models.ModelesVues.ClesPersonnes;
import rdvmedecins.models.ModelesVues.EtatFormulaire;
import rdvmedecins.models.ModelesVues.FormulairePersonneModele;
import rdvmedecins.models.ModelesVues.ListePersonnesModele;
import rdvmedecins.models.ModelesVues.Message;
import rdvmedecins.models.ModelesVues.PersonneVue;
import rdvmedecins.models.ModelesVues.ValeursPersonne;
import rdvmedecins.web.MessageFlash;
import rdvmedecins.web.Validation;
@PreAuthorize("hasRole('ADMIN')")
public abstract class PersonnesControllerBase<T extends Personne> {
// -----------------------------------------------------------------------
// ce que chaque sous-classe doit fournir. Les cinq derniers champs sont
// des FONCTIONS (interfaces fonctionnelles de java.util.function) : la
// sous-classe y range des références de méthodes, application::getAllMedecins...
// -----------------------------------------------------------------------
protected record ConfigPersonnes<T>(
String url, // "/medecins" ou "/clients"
ClesPersonnes cles,
Supplier<List<T>> lister, // () -> liste
Function<Integer, Optional<T>> trouver, // id -> personne
Function<DonneesPersonne, T> ajouter, // données -> personne créée
BiFunction<Integer, DonneesPersonne, T> modifier, // (id, données) -> personne modifiée
IntConsumer supprimer) { // id -> rien
}
protected abstract ConfigPersonnes<T> config();
// les vues communes
private static final String VUE_LISTE = "personnes/liste";
private static final String VUE_FORMULAIRE = "personnes/formulaire";
// -----------------------------------------------------------------------
// GET /medecins (ou /clients)
// -----------------------------------------------------------------------
@GetMapping
public String liste(Model model) {
ConfigPersonnes<T> config = config();
List<PersonneVue> personnes = config.lister().get().stream()
.map(p -> new PersonneVue(p.getTitre(), p.getPrenom(), p.getNom(), login(p),
config.url() + "/" + p.getId() + "/modifier",
config.url() + "/" + p.getId() + "/supprimer"))
.toList();
model.addAttribute("modele", new ListePersonnesModele(config.cles().titre(), null, config.cles(),
config.url() + "/ajouter", personnes));
return VUE_LISTE;
}
// -----------------------------------------------------------------------
// GET /medecins/ajouter
// -----------------------------------------------------------------------
@GetMapping("/ajouter")
public String formulaireAjout(Model model) {
return afficherFormulaire(model, null, ValeursPersonne.vide(), Map.of(), null, null);
}
// -----------------------------------------------------------------------
// POST /medecins/ajouter
// [formulaire] a été rempli ET validé par Spring MVC (@Valid) : le
// BindingResult qui le suit dit si la saisie est correcte.
// -----------------------------------------------------------------------
@PostMapping("/ajouter")
public String ajouter(@Valid FormulairePersonne formulaire, BindingResult resultat, Model model,
RedirectAttributes redirection) {
if (resultat.hasErrors()) {
return afficherFormulaire(model, null, valeursSaisies(formulaire), Validation.erreursDe(resultat),
null, null);
}
try {
T personne = config().ajouter().apply(donnees(formulaire));
redirection.addFlashAttribute(MessageFlash.ATTRIBUT,
MessageFlash.succes(config().cles().ajoute(), parametres(personne)));
return "redirect:" + config().url();
} catch (MetierException e) {
return afficherErreurMetier(model, redirection, null, valeursSaisies(formulaire), e);
}
}
// -----------------------------------------------------------------------
// GET /medecins/{id}/modifier
// {id:\d+} : l'id doit être fait de chiffres - /medecins/abc/modifier ne
// correspond à aucune route (page 404)
// -----------------------------------------------------------------------
@GetMapping("/{id:\\d+}/modifier")
public String formulaireModification(@PathVariable int id, Model model) {
T personne = config().trouver().apply(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
ValeursPersonne valeurs = new ValeursPersonne(personne.getTitre(), personne.getPrenom(), personne.getNom(),
login(personne), personne.getVersion());
return afficherFormulaire(model, id, valeurs, Map.of(), null, null);
}
// -----------------------------------------------------------------------
// POST /medecins/{id}/modifier
// -----------------------------------------------------------------------
@PostMapping("/{id:\\d+}/modifier")
public String modifier(@PathVariable int id, @Valid FormulairePersonne formulaire, BindingResult resultat,
Model model, RedirectAttributes redirection) {
if (resultat.hasErrors()) {
return afficherFormulaire(model, id, valeursSaisies(formulaire), Validation.erreursDe(resultat),
null, null);
}
try {
T personne = config().modifier().apply(id, donnees(formulaire));
redirection.addFlashAttribute(MessageFlash.ATTRIBUT,
MessageFlash.succes(config().cles().modifie(), parametres(personne)));
return "redirect:" + config().url();
} catch (MetierException e) {
return afficherErreurMetier(model, redirection, id, valeursSaisies(formulaire), e);
}
}
// -----------------------------------------------------------------------
// POST /medecins/{id}/supprimer
// (la confirmation "Êtes-vous sûr ?" est demandée avant l'envoi du
// formulaire, cf. static/js/app.js)
// -----------------------------------------------------------------------
@PostMapping("/{id:\\d+}/supprimer")
public String supprimer(@PathVariable int id, RedirectAttributes redirection) {
Optional<T> personne = config().trouver().apply(id);
MessageFlash flash;
try {
config().supprimer().accept(id);
flash = MessageFlash.succes(config().cles().supprime(), parametres(personne.orElse(null)));
} catch (MetierException e) {
flash = MessageFlash.erreur(e.getCle(), e.getParametres());
}
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, flash);
return "redirect:" + config().url();
}
// ----------------------------------------------------------------- méthodes privées
// id null : formulaire de création ; sinon : formulaire de modification.
// erreurs : champ -> clé du message ; erreur : le message de bas de page ;
// autresChamps : des champs à encadrer en rouge en plus
private String afficherFormulaire(Model model, Integer id, ValeursPersonne valeurs,
Map<String, String> erreurs, String erreur, List<String> autresChamps) {
ConfigPersonnes<T> config = config();
boolean creation = id == null;
EtatFormulaire etat = Validation.etat(erreurs, autresChamps);
model.addAttribute("modele", new FormulairePersonneModele(
creation ? config.cles().ajouter() : config.cles().modifier(),
Validation.messagePage(erreurs, erreur == null ? null : Message.de(erreur)),
valeurs,
etat,
creation,
creation ? config.url() + "/ajouter" : config.url() + "/" + id + "/modifier",
config.url()));
return VUE_FORMULAIRE;
}
// une règle de gestion a refusé l'écriture : on réaffiche le formulaire,
// avec la saisie de l'utilisateur et le message de la couche métier
private String afficherErreurMetier(Model model, RedirectAttributes redirection, Integer id,
ValeursPersonne valeurs, MetierException e) {
if (e.getCle().equals("ERRORS.DOCTOR_NOT_FOUND") || e.getCle().equals("ERRORS.PATIENT_NOT_FOUND")) {
// supprimé entre-temps par un autre ADMIN : retour à la liste
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur(e.getCle()));
return "redirect:" + config().url();
}
// le message va en bas de page ; les champs concernés, s'il y en a, sont
// encadrés en rouge pour que l'utilisateur voie tout de suite quoi corriger
return afficherFormulaire(model, id, valeurs, Map.of(), e.getCle(),
Validation.CHAMPS_ERREURS_METIER.get(e.getCle()));
}
// ce que l'utilisateur a saisi (mot de passe exclu : on ne le renvoie
// jamais au navigateur)
private static ValeursPersonne valeursSaisies(FormulairePersonne f) {
return new ValeursPersonne(
f.getTitre() == null ? "" : f.getTitre(),
f.getPrenom() == null ? "" : f.getPrenom(),
f.getNom() == null ? "" : f.getNom(),
f.getLogin() == null ? "" : f.getLogin(),
f.getVersion());
}
// le formulaire validé -> les données attendues par la couche métier
private static DonneesPersonne donnees(FormulairePersonne f) {
return new DonneesPersonne(f.getTitre(), f.getNom(), f.getPrenom(), f.getLogin(), f.getPassword(),
f.getVersion());
}
// {nom} et {login} des messages "ajouté / modifié / supprimé"
private static Map<String, String> parametres(Personne personne) {
return personne == null
? Map.of("nom", "", "login", "")
: Map.of("nom", personne.getPrenom() + " " + personne.getNom(), "login", login(personne));
}
private static String login(Personne p) {
return p.getUser() == null ? "" : p.getUser().getLogin();
}
}
|
La gestion des médecins et celle des clients sont identiques. Plutôt que d'écrire deux contrôleurs semblables, on écrit une seule fois leurs actions, dans une classe mère abstraite et générique. Commentons ce code :
- lignes 15-20 : les routes obtenues pour les médecins ; les mêmes existent sous [/clients] ;
- lignes 22-23 : les vues sont communes elles aussi (dossier [templates/personnes]) ;
- lignes 25-26 : la classe n'est pas annotée [@Controller], et, étant abstraite, ne pourrait de toute façon pas être instanciée : seules ses sous-classes [MedecinsController] et [ClientsController] sont des beans, et reçoivent des requêtes ;
- lignes 28-33 et ligne 70 : la restriction au rôle ADMIN, [@PreAuthorize("hasRole('ADMIN')")], est posée sur cette classe. Posée sur une classe, l'annotation vaut pour toutes ses méthodes. Mais pour décider quelle règle s'applique à une action, [Spring Security] regarde l'annotation de la méthode, puis celle de la classe qui déclare la méthode. Or les actions [liste], [ajouter]... sont déclarées ici, dans [PersonnesControllerBase], et seulement héritées par [MedecinsController]. Posée sur [MedecinsController], l'annotation serait ignorée pour elles : un patient (USER) pourrait ouvrir [/medecins]. C'est une différence avec [ASP.NET Core MVC], où l'attribut [[Authorize(Roles = "ADMIN")]] d'une sous-classe s'applique aussi aux actions héritées ;
- ligne 71 : la classe est abstraite ([abstract] : on ne peut pas en créer d'objet, elle n'existe que pour être dérivée) et générique : [T] est un paramètre de type, que chaque sous-classe remplace par [Medecin] ou [Client]. [T extends Personne] est une contrainte : [T] doit être [Personne] ou l'une de ses classes dérivées ; le code peut donc utiliser [p.getPrenom()] ou [p.getUser()], communs à toutes les personnes ;
- lignes 78-86 : la configuration que chaque sous-classe fournit, sous la forme d'un [record] : l'URL de base, les textes (des clés de traduction, regroupées dans le [record] [ClesPersonnes] de [models/ModelesVues.java]), et cinq fonctions à appeler pour lister, trouver, ajouter, modifier, supprimer. Une fonction rangée dans une variable a pour type une interface fonctionnelle du paquetage [java.util.function] (cf. « La couche métier ») : [Supplier<R>] (aucun paramètre, rend un [R], méthode [get]), [Function<P, R>] (un paramètre [P], rend un [R], méthode [apply]), [BiFunction<P1, P2, R>] (deux paramètres), [IntConsumer] (un [int], ne rend rien, méthode [accept]). [protected] : le [record] n'est visible que de la classe et de ses sous-classes (et du paquetage) ;
- ligne 88 : une méthode abstraite : chaque sous-classe doit l'écrire, pour rendre sa configuration ;
- lignes 91-92 : les deux vues, désignées par leur chemin dans le dossier [templates] ;
- lignes 97-108 : la liste. Les annotations de routage posées sur les méthodes d'une classe mère sont héritées : combinées au [@RequestMapping("/medecins")] de la sous-classe, elles donnent [GET /medecins]. [config.lister().get()] appelle la fonction rangée dans la configuration. Le modèle contient, pour chaque personne, les URL de ses boutons Modifier et Supprimer ;
- lignes 113-116 : le formulaire vide. [Map.of()] est un dictionnaire vide : aucune erreur ;
- lignes 123-138 : l'ajout, en trois temps, comme à l'exemple 28. Le paramètre [formulaire] a été rempli par Spring MVC avec les champs envoyés, puis validé d'après ses annotations ([@NotBlank], [@Size]..., cf. [models/Formulaires.java]) parce qu'il est annoté [@Valid] (cf. exemple 19). Le résultat de la validation est dans le paramètre [BindingResult] qui le suit immédiatement : une saisie invalide réaffiche le formulaire, avec les valeurs saisies et un message sous chaque champ erroné ; une saisie valide est confiée à la couche métier, puis le navigateur est redirigé vers la liste, avec un message flash (PRG) ; un refus de la couche métier (un homonyme, un login déjà pris) réaffiche le formulaire ;
- lignes 145-152 : le formulaire de modification, prérempli. [{id:\\d+}] est une variable de chemin assortie d'une expression régulière : l'identifiant doit être fait de chiffres ([\d+], le [\] étant doublé dans une chaîne [Java]). [/medecins/abc/modifier] ne correspond donc à aucune route, d'où une page 404. Une personne inexistante donne également une 404 : [orElseThrow] extrait la valeur de l'[Optional], ou, s'il est vide, lève l'exception fabriquée par la lambda. Le numéro de version, champ caché du formulaire, servira au verrou optimiste (cf. exemple 28) ;
- lignes 157-172 : la modification, sur le même modèle que l'ajout ;
- lignes 179-191 : la suppression. La personne est lue avant, pour afficher son nom dans le message de succès ([orElse(null)] : la personne, ou [null] si elle n'existait pas). Un refus de la couche métier (un médecin qui a des rendez-vous) devient un message flash d'erreur sur la page de la liste ;
- lignes 198-212 : le formulaire, pour la création ([id] vaut [null]) comme pour la modification : seuls le titre et l'adresse d'envoi changent. [Validation.etat] et [Validation.messagePage] (dossier [web]) préparent les erreurs par champ, les champs à encadrer en rouge et le message du bas de page ;
- lignes 216-227 : une personne supprimée entre-temps (par un autre administrateur) renvoie à la liste ; les autres erreurs métier réaffichent le formulaire, avec le message en bas de page et les champs concernés encadrés en rouge ([Validation.CHAMPS_ERREURS_METIER] associe à chaque clé d'erreur ses champs : [prenom] et [nom] pour un homonyme) ;
- lignes 231-238 : les valeurs à réafficher, jamais le mot de passe ;
- lignes 241-244 : les données transmises à la couche métier ;
- lignes 247-251 : les paramètres [{nom}] et [{login}] des messages de succès : le prénom et le nom de la personne, et son login ;
- lignes 253-255 : le login du compte associé (une chaîne vide si le compte manquait, par simple précaution : toute personne a un compte).
11.16.4. controllers/MedecinsController.java
| // =========================================================================
// controllers/MedecinsController.java
// -------------------------------------------------------------------------
// Gestion des médecins (rôle ADMIN uniquement). Toutes les actions sont
// héritées de PersonnesControllerBase : il ne reste ici qu'à dire QUOI
// appeler et QUELS libellés afficher.
//
// La restriction au rôle ADMIN est posée sur la classe mère (cf. le
// commentaire "SÉCURITÉ" de PersonnesControllerBase).
// =========================================================================
package rdvmedecins.controllers;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import rdvmedecins.entities.Medecin;
import rdvmedecins.models.ModelesVues.ClesPersonnes;
import rdvmedecins.web.ApplicationModel;
@Controller
@RequestMapping("/medecins")
public class MedecinsController extends PersonnesControllerBase<Medecin> {
private final ConfigPersonnes<Medecin> config;
public MedecinsController(ApplicationModel application) {
// application::getAllMedecins : une "référence de méthode", la
// méthode getAllMedecins de l'objet application, rangée dans une variable
this.config = new ConfigPersonnes<>(
"/medecins",
new ClesPersonnes(
"MEDECINS.TITLE",
"MEDECINS.ADD",
"MEDECINS.EDIT",
"MEDECINS.EMPTY",
"MEDECINS.DELETE_CONFIRM",
"MEDECINS.ADDED",
"MEDECINS.UPDATED",
"MEDECINS.DELETED"),
application::getAllMedecins,
application::getMedecinById,
application::ajouterMedecin,
application::modifierMedecin,
application::supprimerMedecin);
}
@Override
protected ConfigPersonnes<Medecin> config() {
return config;
}
}
|
- lignes 8-9 : la restriction au rôle ADMIN est portée par la classe mère, pour la raison expliquée plus haut ;
- lignes 20-22 : un contrôleur ([@Controller]) dont toutes les routes commencent par [/medecins]. Il hérite ([extends]) de [PersonnesControllerBase<Medecin>] : le type [T] de la classe mère devient [Medecin] ;
- lignes 26-45 : le constructeur reçoit [ApplicationModel] par injection, et fabrique une fois pour toutes la configuration : l'URL de base et les clés de traduction propres aux médecins ;
- lignes 40-44 : les fonctions à appeler, qui sont des méthodes d'[ApplicationModel]. [application::getAllMedecins] est une référence de méthode : elle n'appelle pas la méthode, elle la désigne. Le compilateur la convertit en une valeur de l'interface fonctionnelle attendue par le [record] ([Supplier<List<Medecin>>] pour la première), dont la méthode, [get], appellera [application.getAllMedecins()]. C'est une écriture abrégée de la lambda [() -> application.getAllMedecins()] ;
- ligne 29 : [new ConfigPersonnes<>(...)] : l'opérateur [<>], dit « diamant », laisse le compilateur déduire le type générique ([Medecin]) ;
- lignes 47-50 : la méthode abstraite de la classe mère, écrite ici. [@Override] demande au compilateur de vérifier qu'elle redéfinit bien une méthode de la classe mère.
Nous avons vu, avec l'authentification, qu'un patient qui demande [/medecins] reçoit la page 403 : la protection posée sur la classe mère fonctionne donc. On remarque de plus que la barre de navigation de cette page d'erreur affiche l'utilisateur (cf. la méthode [utilisateur] de [auth/AuthService.java]). Avec le cookie du jeton de Mr Jean MARTIN (rôle USER) :
| curl -i -b "rdvmedecins_jwt=..." http://localhost:8081/medecins
|
| HTTP/1.1 403
Content-Type: text/html;charset=UTF-8
<!doctype html>
...
<span class="badge text-bg-light text-primary">USER</span>
<span class="text-white">Jean MARTIN</span>
...
<h1 class="display-4 text-danger">403</h1>
<p class="lead">Votre rôle ne vous permet pas d'accéder à cette page.</p>
...
|
11.16.5. controllers/ClientsController.java
| // =========================================================================
// controllers/ClientsController.java
// -------------------------------------------------------------------------
// Gestion des clients (les PATIENTS du cabinet), rôle ADMIN uniquement.
// Même principe que MedecinsController (voir ses commentaires).
// =========================================================================
package rdvmedecins.controllers;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import rdvmedecins.entities.Client;
import rdvmedecins.models.ModelesVues.ClesPersonnes;
import rdvmedecins.web.ApplicationModel;
@Controller
@RequestMapping("/clients")
public class ClientsController extends PersonnesControllerBase<Client> {
private final ConfigPersonnes<Client> config;
public ClientsController(ApplicationModel application) {
this.config = new ConfigPersonnes<>(
"/clients",
new ClesPersonnes(
"CLIENTS.TITLE",
"CLIENTS.ADD",
"CLIENTS.EDIT",
"CLIENTS.EMPTY",
"CLIENTS.DELETE_CONFIRM",
"CLIENTS.ADDED",
"CLIENTS.UPDATED",
"CLIENTS.DELETED"),
application::getAllClients,
application::getClientById,
application::ajouterClient,
application::modifierClient,
application::supprimerClient);
}
@Override
protected ConfigPersonnes<Client> config() {
return config;
}
}
|
Le même contrôleur, pour les clients : autre route (lignes 16-18), autres clés de traduction, autres méthodes d'[ApplicationModel] (lignes 34-38).
11.16.6. controllers/InscriptionController.java
| // =========================================================================
// controllers/InscriptionController.java
// -------------------------------------------------------------------------
// "Créer un compte client" : un patient qui n'a pas encore de compte le
// crée lui-même. Routes NON protégées (on n'a pas encore de compte, par
// définition) :
//
// POST /inscription/acces bouton "Créer un compte client" de la page
// d'authentification : vérifie le captcha de
// CETTE page, puis ouvre le formulaire
// GET /inscription le formulaire
// POST /inscription la création, puis retour à la page de connexion
//
// Le parcours est protégé contre les robots par UN captcha, celui de la page
// d'authentification :
// 1. le bouton "Créer un compte client" envoie le formulaire de connexion
// vers /inscription/acces ; si le captcha est bien recopié, le serveur
// délivre un laissez-passer (cf. auth/PasseInscriptionService.java) ;
// 2. sans laissez-passer valide, /inscription renvoie à la page
// d'authentification - taper l'URL directement ne sert à rien ;
// 3. le laissez-passer est consommé à la création : un captcha résolu
// permet de créer UN compte ;
// 4. en plus, le nombre de comptes créés par heure et par adresse IP est
// limité (cf. auth/LimiteurTentativesService.java).
//
// Le compte créé est exactement celui que créerait l'ADMIN depuis la page
// "Clients" (même méthode de la couche métier : ajouterClient), si ce
// n'est que le patient CHOISIT son login et son mot de passe (obligatoires,
// avec confirmation).
// =========================================================================
package rdvmedecins.controllers;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import rdvmedecins.auth.CaptchaService;
import rdvmedecins.auth.LimiteurTentativesService;
import rdvmedecins.auth.PasseInscriptionService;
import rdvmedecins.metier.DonneesPersonne;
import rdvmedecins.metier.MetierException;
import rdvmedecins.models.Formulaires.FormulaireInscription;
import rdvmedecins.models.ModelesVues.InscriptionModele;
import rdvmedecins.models.ModelesVues.Message;
import rdvmedecins.models.ModelesVues.ValeursPersonne;
import rdvmedecins.web.ApplicationModel;
import rdvmedecins.web.MessageFlash;
import rdvmedecins.web.Validation;
@Controller
@RequestMapping("/inscription")
public class InscriptionController {
private final ApplicationModel application;
private final CaptchaService captchaService;
private final LimiteurTentativesService limiteur;
private final PasseInscriptionService passes;
public InscriptionController(ApplicationModel application, CaptchaService captchaService,
LimiteurTentativesService limiteur, PasseInscriptionService passes) {
this.application = application;
this.captchaService = captchaService;
this.limiteur = limiteur;
this.passes = passes;
}
// -----------------------------------------------------------------------
// POST /inscription/acces champs : captchaId, captcha (ceux de la page
// d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
// -----------------------------------------------------------------------
@PostMapping("/acces")
public String acces(@RequestParam(required = false) String captchaId,
@RequestParam(required = false) String captcha,
HttpServletRequest request, HttpServletResponse response, RedirectAttributes redirection) {
String ip = AuthController.adresseIp(request);
// trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
long minutes = limiteur.minutesBlocageInscription(ip);
if (minutes > 0) {
redirection.addFlashAttribute(MessageFlash.ATTRIBUT,
MessageFlash.erreur("ERRORS.TOO_MANY_ACCOUNTS", Map.of("minutes", String.valueOf(minutes))));
return "redirect:/login";
}
// captcha absent ou faux : retour à la page d'authentification, avec
// une nouvelle image (un échec compte comme un échec de connexion)
if (!captchaService.verifier(captchaId, captcha)) {
limiteur.echecLogin(ip, "");
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.CAPTCHA_BEFORE_ACCOUNT"));
return "redirect:/login";
}
// captcha correct : on délivre le laissez-passer et on ouvre le formulaire
response.addHeader(HttpHeaders.SET_COOKIE, PasseInscriptionService.cookie(passes.emettre()).toString());
return "redirect:/inscription";
}
// -----------------------------------------------------------------------
// GET /inscription
// @CookieValue : la valeur du cookie de ce nom (null s'il est absent)
// -----------------------------------------------------------------------
@GetMapping
public String formulaire(@CookieValue(name = PasseInscriptionService.COOKIE, required = false) String passe,
Model model, RedirectAttributes redirection) {
if (!passes.estValide(passe)) {
return retourLogin(redirection);
}
return afficher(model, ValeursPersonne.vide(), Map.of(), null, null);
}
// -----------------------------------------------------------------------
// POST /inscription
// Le paramètre [formulaire] est rempli et VALIDÉ par Spring MVC avant
// l'appel : le résultat de la validation est dans [resultat].
// -----------------------------------------------------------------------
@PostMapping
public String creer(@Valid FormulaireInscription formulaire, BindingResult resultat,
@CookieValue(name = PasseInscriptionService.COOKIE, required = false) String passe,
HttpServletRequest request, HttpServletResponse response, Model model,
RedirectAttributes redirection) {
if (!passes.estValide(passe)) {
return retourLogin(redirection);
}
String ip = AuthController.adresseIp(request);
ValeursPersonne valeurs = new ValeursPersonne(
formulaire.getTitre() == null ? "Mr" : formulaire.getTitre(),
formulaire.getPrenom() == null ? "" : formulaire.getPrenom(),
formulaire.getNom() == null ? "" : formulaire.getNom(),
formulaire.getLogin() == null ? "" : formulaire.getLogin(),
null);
// 1. trop de comptes créés récemment depuis cette adresse ?
long minutes = limiteur.minutesBlocageInscription(ip);
if (minutes > 0) {
return afficher(model, valeurs, Map.of(),
new Message("ERRORS.TOO_MANY_ACCOUNTS", Map.of("minutes", String.valueOf(minutes))), null);
}
// 2. toutes les erreurs de saisie d'un coup (champs + confirmation) :
// l'utilisateur les corrige toutes en une fois
Map<String, String> erreurs = new HashMap<>(Validation.erreursDe(resultat));
if (!erreurs.containsKey("password") && !erreurs.containsKey("confirmation")
&& !formulaire.getPassword().equals(formulaire.getConfirmation())) {
erreurs.put("confirmation", "ERRORS.PASSWORDS_DIFFER");
}
if (!erreurs.isEmpty()) {
return afficher(model, valeurs, erreurs, null, null);
}
// 3. la création (client + compte USER, dans une transaction)
try {
application.ajouterClient(new DonneesPersonne(formulaire.getTitre(), formulaire.getNom(),
formulaire.getPrenom(), formulaire.getLogin(), formulaire.getPassword(), null));
} catch (MetierException e) {
// login ou prénom + nom déjà pris : le laissez-passer reste valable
return afficher(model, valeurs, Map.of(), Message.de(e.getCle()),
Validation.CHAMPS_ERREURS_METIER.get(e.getCle()));
}
// 4. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
passes.consommer(passe);
response.addHeader(HttpHeaders.SET_COOKIE, PasseInscriptionService.cookieEfface().toString());
limiteur.inscriptionReussie(ip);
// 5. retour à la page de connexion, login pré-rempli
redirection.addFlashAttribute(MessageFlash.ATTRIBUT,
MessageFlash.succes("INSCRIPTION.DONE", Map.of("login", formulaire.getLogin())));
return "redirect:/login?login=" + UriUtils.encodeQueryParam(formulaire.getLogin(), StandardCharsets.UTF_8);
}
// ----------------------------------------------------------------- méthodes privées
// pas de laissez-passer valide : retour à la page d'authentification
private static String retourLogin(RedirectAttributes redirection) {
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.CAPTCHA_BEFORE_ACCOUNT"));
return "redirect:/login";
}
// le formulaire ; jamais de mot de passe renvoyé au navigateur
private static String afficher(Model model, ValeursPersonne valeurs, Map<String, String> erreurs,
Message erreur, List<String> autresChamps) {
model.addAttribute("modele", new InscriptionModele("INSCRIPTION.TITLE",
Validation.messagePage(erreurs, erreur), valeurs, Validation.etat(erreurs, autresChamps)));
return "inscription";
}
}
|
Commentons ce code :
- lignes 14-24 : la protection contre les robots, en quatre points. Le captcha et le limiteur de tentatives ont été présentés à l'exemple 27 et avec l'authentification ;
- lignes 66-81 : les routes [/inscription...] sont déclarées publiques dans [SecuriteConfig] ([permitAll()]) et le contrôleur n'a aucune annotation [@PreAuthorize] : ses pages sont ouvertes à tous. Il reçoit quatre services par injection ;
- lignes 87-90 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (attribut [formaction], cf. exemple 21). Seuls les champs du captcha sont lus ;
- ligne 91 : l'adresse IP du navigateur, par une méthode statique d'[AuthController] ;
- lignes 94-99 : trop de comptes créés depuis cette adresse dans l'heure : retour à la page de connexion, avec un message flash qui donne le nombre de minutes d'attente. [Map.of("minutes", ...)] fabrique le dictionnaire des paramètres du message ;
- lignes 103-107 : un captcha absent ou faux compte comme un échec de connexion (avec un login vide) : un robot ne peut pas essayer indéfiniment. Le retour à la page de connexion affiche une nouvelle image ;
- lignes 110-111 : le captcha est correct : un laissez-passer (cf. [auth/PasseInscriptionService.java]) est déposé dans un cookie, par un entête [Set-Cookie] ajouté à la réponse, et le navigateur est redirigé vers le formulaire ;
- lignes 118-125 : le formulaire, seulement avec un laissez-passer valide. Taper directement l'adresse [/inscription] ne sert à rien. [@CookieValue] lie le paramètre [passe] à la valeur du cookie de ce nom ([null] s'il est absent, grâce à [required = false]) ;
- lignes 132-136 : la création. Le formulaire [FormulaireInscription] (dossier [models]) a été lié et validé par Spring MVC ([@Valid]), le résultat étant dans [resultat] ;
- lignes 137-139 : le laissez-passer est vérifié de nouveau : la requête POST pourrait avoir été envoyée sans passer par le formulaire ;
- lignes 141-146 : les valeurs à réafficher en cas d'erreur, sans le mot de passe. Le titre par défaut est [Mr] ;
- lignes 149-153 : la limite horaire, vérifiée de nouveau au moment de la création ;
- lignes 157-164 : les erreurs de saisie, champ par champ (cf. exemple 19). [Validation.erreursDe] en fait un dictionnaire (champ, clé du message), recopié dans un [HashMap] pour pouvoir le compléter. L'égalité du mot de passe et de sa confirmation, qui porte sur deux champs, est vérifiée ici, à la main ; elle n'est testée que si ces deux champs sont par ailleurs corrects. Toutes les erreurs sont signalées ensemble : l'utilisateur les corrige en une fois ;
- lignes 167-174 : la création du client et de son compte USER, par la méthode de la couche métier qu'utilise l'ADMIN. Un login ou un couple prénom-nom déjà pris réaffiche le formulaire, les champs concernés encadrés en rouge ; le laissez-passer reste valable ;
- lignes 177-179 : le laissez-passer a servi : il est consommé côté serveur et son cookie est effacé. La création est enfin comptée pour la limite horaire ;
- lignes 182-184 : retour à la page de connexion, login prérempli, avec un message de succès. [UriUtils.encodeQueryParam] encode le login pour qu'il puisse figurer dans une URL ;
- lignes 190-193 : sans laissez-passer, retour à la page de connexion avec un message ;
- lignes 196-201 : le formulaire, préparé par [Validation] comme celui des médecins et des clients. Une méthode [static] n'utilise aucun champ de l'objet : elle peut être appelée sans lui.
11.16.7. controllers/EspacePatientController.java
| // =========================================================================
// controllers/EspacePatientController.java
// -------------------------------------------------------------------------
// Les pages PERSONNELLES du patient (rôle USER), celle qu'il voit juste
// après s'être connecté :
//
// GET /mon-espace page d'accueil : [Gérer mon compte],
// [Voir un agenda], et son rendez-vous à venir
// POST /mon-espace/annuler annule SON rendez-vous
// GET /mon-compte formulaire de modification de son compte
// POST /mon-compte modification
// POST /mon-compte/supprimer suppression du compte (et de ses rendez-vous)
//
// Règle d'or de ce contrôleur : le patient concerné est TOUJOURS celui du
// jeton (Outils.patientConnecte), jamais un identifiant envoyé par le
// navigateur. Un patient ne peut donc ni voir, ni modifier, ni annuler ce qui
// appartient à un autre patient, même en fabriquant lui-même ses requêtes.
//
// Toute modification ou suppression du compte exige le mot de passe
// ACTUEL : quelqu'un qui trouverait une session restée ouverte ne pourrait
// ni changer le mot de passe, ni supprimer le compte. Les erreurs de mot de
// passe actuel sont comptées comme des échecs de connexion (même limite).
// =========================================================================
package rdvmedecins.controllers;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import rdvmedecins.auth.AuthService;
import rdvmedecins.auth.LimiteurTentativesService;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.DonneesPersonne;
import rdvmedecins.metier.MetierException;
import rdvmedecins.models.Formulaires.FormulaireMonCompte;
import rdvmedecins.models.ModelesVues.EspaceModele;
import rdvmedecins.models.ModelesVues.Message;
import rdvmedecins.models.ModelesVues.MonCompteModele;
import rdvmedecins.models.ModelesVues.RendezVousVue;
import rdvmedecins.models.ModelesVues.ValeursPersonne;
import rdvmedecins.web.ApplicationModel;
import rdvmedecins.web.MessageFlash;
import rdvmedecins.web.Outils;
import rdvmedecins.web.Validation;
@Controller
@PreAuthorize("hasRole('USER')")
public class EspacePatientController {
private final ApplicationModel application;
private final AuthService authService;
private final LimiteurTentativesService limiteur;
public EspacePatientController(ApplicationModel application, AuthService authService,
LimiteurTentativesService limiteur) {
this.application = application;
this.authService = authService;
this.limiteur = limiteur;
}
// -----------------------------------------------------------------------
// GET /mon-espace
// -----------------------------------------------------------------------
@GetMapping("/mon-espace")
public String espace(@AuthenticationPrincipal Jwt jeton, Locale locale, Model model) {
Client patient = patient(jeton);
List<RendezVousVue> rendezVous = application.getRvAVenirClient(patient.getId()).stream()
.map(rv -> new RendezVousVue(
rv.getId(),
Outils.formaterJour(rv.getJour(), locale),
Outils.horaire(rv.getCreneau()),
Outils.libelle(rv.getCreneau().getMedecin()),
// lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
"/agenda?idMedecin=" + rv.getCreneau().getIdMedecin() + "&jour=" + Outils.iso(rv.getJour())))
.toList();
model.addAttribute("modele", new EspaceModele("ESPACE.TITLE", null, Outils.libelle(patient), rendezVous));
return "espace";
}
// -----------------------------------------------------------------------
// POST /mon-espace/annuler champ : idRv
// -----------------------------------------------------------------------
@PostMapping("/mon-espace/annuler")
public String annulerRv(@RequestParam(required = false) String idRv, @AuthenticationPrincipal Jwt jeton,
RedirectAttributes redirection) {
Client patient = patient(jeton);
Integer id = Outils.entierPositif(idRv);
Optional<Rv> rv = id == null ? Optional.empty() : application.getRvById(id);
// un rendez-vous qui n'existe pas, et celui d'un AUTRE patient, reçoivent
// la même réponse : on ne révèle rien sur les rendez-vous des autres
if (rv.isEmpty() || !rv.get().getIdClient().equals(patient.getId())) {
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.erreur("ERRORS.APPOINTMENT_NOT_FOUND"));
} else {
application.supprimerRv(rv.get());
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.succes("AGENDA.CANCELLED"));
}
return "redirect:/mon-espace";
}
// -----------------------------------------------------------------------
// GET /mon-compte
// -----------------------------------------------------------------------
@GetMapping("/mon-compte")
public String formulaireCompte(@AuthenticationPrincipal Jwt jeton, Model model) {
Client patient = patient(jeton);
ValeursPersonne valeurs = new ValeursPersonne(patient.getTitre(), patient.getPrenom(), patient.getNom(),
patient.getUser().getLogin(), patient.getVersion());
return afficher(model, valeurs, Map.of(), null, null);
}
// -----------------------------------------------------------------------
// POST /mon-compte
// -----------------------------------------------------------------------
@PostMapping("/mon-compte")
public String modifierCompte(@Valid FormulaireMonCompte formulaire, BindingResult resultat,
@AuthenticationPrincipal Jwt jeton, HttpServletRequest request, HttpServletResponse response,
Model model, RedirectAttributes redirection) {
Client patient = patient(jeton);
ValeursPersonne valeurs = new ValeursPersonne(
formulaire.getTitre() == null ? patient.getTitre() : formulaire.getTitre(),
formulaire.getPrenom() == null ? "" : formulaire.getPrenom(),
formulaire.getNom() == null ? "" : formulaire.getNom(),
formulaire.getLogin() == null ? "" : formulaire.getLogin(),
formulaire.getVersion());
Map<String, String> erreurs = new HashMap<>(Validation.erreursDe(resultat));
if (!erreurs.containsKey("password") && formulaire.getPassword() != null
&& !formulaire.getPassword().equals(formulaire.getConfirmation())) {
erreurs.put("confirmation", "ERRORS.PASSWORDS_DIFFER");
}
if (!erreurs.isEmpty()) {
return afficher(model, valeurs, erreurs, null, null);
}
// le mot de passe actuel
Message erreurMotDePasse = verifierMotDePasseActuel(request, patient, formulaire.getMotDePasseActuel());
if (erreurMotDePasse != null) {
// mot de passe faux : le message va aussi SOUS le champ
Map<String, String> erreursChamp = erreurMotDePasse.cle().equals("ERRORS.WRONG_CURRENT_PASSWORD")
? Map.of("motDePasseActuel", erreurMotDePasse.cle())
: Map.of();
return afficher(model, valeurs, erreursChamp, erreurMotDePasse, null);
}
try {
Client client = application.modifierClient(patient.getId(), new DonneesPersonne(formulaire.getTitre(),
formulaire.getNom(), formulaire.getPrenom(), formulaire.getLogin(),
formulaire.getPassword(), // null = inchangé
formulaire.getVersion()));
// le login et le nom affiché sont DANS le jeton : on en délivre un
// nouveau, sinon la barre de navigation garderait l'ancien nom
authService.deposerJeton(response, client.getUser());
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.succes("MON_COMPTE.UPDATED"));
return "redirect:/mon-espace";
} catch (MetierException e) {
return afficher(model, valeurs, Map.of(), Message.de(e.getCle()),
Validation.CHAMPS_ERREURS_METIER.get(e.getCle()));
}
}
// -----------------------------------------------------------------------
// POST /mon-compte/supprimer champ : motDePasseSuppression
// Le compte, le client et TOUS ses rendez-vous disparaissent ensemble,
// puis le patient est déconnecté.
// -----------------------------------------------------------------------
@PostMapping("/mon-compte/supprimer")
public String supprimerCompte(@RequestParam(required = false) String motDePasseSuppression,
@AuthenticationPrincipal Jwt jeton, HttpServletRequest request, HttpServletResponse response,
RedirectAttributes redirection) {
Client patient = patient(jeton);
Message erreurMotDePasse = verifierMotDePasseActuel(request, patient, motDePasseSuppression);
if (erreurMotDePasse != null) {
redirection.addFlashAttribute(MessageFlash.ATTRIBUT,
MessageFlash.erreur(erreurMotDePasse.cle(), erreurMotDePasse.parametres()));
return "redirect:/mon-compte";
}
application.supprimerClient(patient.getId());
AuthService.effacerJeton(response);
redirection.addFlashAttribute(MessageFlash.ATTRIBUT, MessageFlash.succes("MON_COMPTE.DELETED"));
return "redirect:/login";
}
// ----------------------------------------------------------------- méthodes privées
// le patient du jeton
private Client patient(Jwt jeton) {
return Outils.patientConnecte(application, Outils.utilisateur(jeton));
}
// null si le mot de passe actuel est correct ; sinon le message d'erreur
private Message verifierMotDePasseActuel(HttpServletRequest request, Client patient, String motDePasse) {
String ip = AuthController.adresseIp(request);
String login = patient.getUser().getLogin();
long minutes = limiteur.minutesBlocageLogin(ip, login);
if (minutes > 0) {
return new Message("ERRORS.TOO_MANY_ATTEMPTS", Map.of("minutes", String.valueOf(minutes)));
}
boolean ok = motDePasse != null && !motDePasse.isEmpty()
&& authService.validerUtilisateur(login, motDePasse).isPresent();
if (!ok) {
limiteur.echecLogin(ip, login);
return Message.de("ERRORS.WRONG_CURRENT_PASSWORD");
}
return null;
}
private static String afficher(Model model, ValeursPersonne valeurs, Map<String, String> erreurs,
Message erreur, List<String> autresChamps) {
model.addAttribute("modele", new MonCompteModele("MON_COMPTE.TITLE",
Validation.messagePage(erreurs, erreur), valeurs, Validation.etat(erreurs, autresChamps)));
return "compte";
}
}
|
Commentons ce code :
- lignes 14-17 : la règle d'or de ce contrôleur : le patient concerné est toujours celui du jeton, jamais un identifiant envoyé par le navigateur ;
- lignes 63-65 : réservé aux patients. Ici, [@PreAuthorize] sur la classe s'applique bien à toutes les actions, puisqu'elles sont toutes déclarées dans cette classe. Le contrôleur n'a pas d'annotation [@RequestMapping] : chaque action donne son adresse complète, car elles ne partagent pas de préfixe ([/mon-espace], [/mon-compte]) ;
- lignes 81-95 : la page « Mon espace ». La méthode [patient] (plus bas) retrouve le client à partir du jeton ; si son compte a été supprimé depuis la connexion, elle lève une [SessionInvalideException], que [web/HtmlExceptionHandler.java] transforme en retour à la page de connexion. La date est formatée par [Outils.formaterJour] dans la langue de la requête (cf. exemple 22) ; chaque rendez-vous porte le lien vers l'agenda du médecin ce jour-là (ligne 91) ;
- lignes 100-115 : l'annulation de son rendez-vous. Ligne 105, un identifiant invalide donne un [Optional] vide ([Optional.empty()]). Un rendez-vous inexistant et celui d'un autre patient (ligne 108) reçoivent la même réponse : on ne révèle rien sur les rendez-vous des autres ;
- lignes 120-126 : le formulaire du compte, prérempli, avec le numéro de version (verrou optimiste) ;
- lignes 131-134 : la modification, en trois temps : validation, vérification du mot de passe actuel, couche métier ;
- lignes 136-141 : les valeurs à réafficher en cas d'erreur, jamais les mots de passe ;
- lignes 143-150 : les erreurs de saisie. Le nouveau mot de passe est facultatif (vide : inchangé) : sa confirmation n'est comparée que s'il a été saisi ;
- lignes 153-160 : le mot de passe actuel. Un mauvais mot de passe est signalé sous son champ et en bas de page ; un blocage (trop de tentatives) seulement en bas de page, avec le nombre de minutes ;
- lignes 162-168 : la modification par la couche métier, qui applique les mêmes règles que pour l'ADMIN (unicité, verrou optimiste) ;
- ligne 169 : le jeton contient le login et le nom affiché : après une modification, on délivre un nouveau jeton, sans quoi la barre de navigation garderait l'ancien nom ;
- lignes 172-175 : un refus de la couche métier réaffiche le formulaire, les champs concernés encadrés en rouge ;
- lignes 183-193 : la suppression du compte, exigeant elle aussi le mot de passe actuel. Une erreur est signalée par un message flash sur la page du compte (PRG) ;
- lignes 194-197 : la suppression de la fiche, du compte et de tous les rendez-vous (la méthode de la couche métier qu'utilise l'ADMIN), puis la déconnexion : le cookie du jeton est effacé ;
- lignes 203-205 : le patient du jeton ;
- lignes 208-222 : la vérification du mot de passe actuel. Elle rend [null] si le mot de passe est correct, sinon le message d'erreur (un [Message] : clé et paramètres). Le mot de passe actuel est soumis au même limiteur que la connexion (cf. exemple 27) : quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot de passe, ni supprimer le compte, ni essayer indéfiniment des mots de passe. La vérification elle-même est confiée à [AuthService] ; un échec est compté comme un échec de connexion ;
- lignes 224-229 : le formulaire du compte.
11.16.8. controllers/ErreurController.java
| // =========================================================================
// controllers/ErreurController.java
// -------------------------------------------------------------------------
// La page d'erreur (400, 403, 404, 405, 500...).
//
// On n'y arrive jamais par un lien. Quand une requête se termine en erreur
// - une exception non traitée, un response.sendError(403), une
// ResponseStatusException(NOT_FOUND)... - le serveur (Tomcat) TRANSMET la
// requête à l'adresse /error, pour fabriquer la page à renvoyer à la place
// d'une réponse vide ou d'une pile d'appels (cf. exemple 24). Il range au
// passage le code d'erreur dans un attribut de la requête.
//
// Spring Boot fournit déjà un contrôleur /error (BasicErrorController, la
// page "Whitelabel Error Page") ; il s'efface dès qu'un bean implémente
// l'interface ErrorController, comme celui-ci.
//
// @RequestMapping sans méthode HTTP : l'action accepte toutes les méthodes -
// la requête transmise garde sa méthode d'origine (un POST refusé reste un POST).
// =========================================================================
package rdvmedecins.controllers;
import java.util.Map;
import org.springframework.boot.webmvc.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import rdvmedecins.models.ModelesVues.ErreurModele;
@Controller
public class ErreurController implements ErrorController {
private static final Map<Integer, String> MESSAGES = Map.of(
HttpServletResponse.SC_BAD_REQUEST, "ERRORS.BAD_REQUEST",
HttpServletResponse.SC_FORBIDDEN, "ERRORS.FORBIDDEN",
HttpServletResponse.SC_NOT_FOUND, "ERRORS.NOT_FOUND");
@RequestMapping("/error")
public String afficher(HttpServletRequest request, HttpServletResponse response, Model model) {
// le code d'erreur d'origine, rangé par le serveur dans la requête
Object statut = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
int code = statut instanceof Integer c ? c : HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
// la page garde ce code (404 reste 404...) ; un code inattendu
// (ou /error appelée directement) devient une erreur 500
if (code < 400 || code > 599) {
code = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
}
response.setStatus(code);
model.addAttribute("modele", new ErreurModele("ERROR_PAGE.TITLE", null, code,
MESSAGES.getOrDefault(code, "ERRORS.SERVER_ERROR")));
return "erreur";
}
}
|
C'est le contrôleur de l'exemple 24, avec des messages traduits :
- lignes 6-11 : on n'arrive jamais sur cette page par un lien. Quand une requête se termine par une erreur (une exception non traitée, une [ResponseStatusException], un [response.sendError(403)] du gestionnaire de refus de [Spring Security]...), le serveur [Tomcat] transmet la requête à l'adresse [/error] pour fabriquer la page à renvoyer, au lieu d'une réponse vide ou d'une pile d'appels ;
- lignes 13-15 et lignes 34-35 : [Spring Boot] fournit déjà un contrôleur [/error], [BasicErrorController], qui produit la page « Whitelabel Error Page ». Il s'efface dès qu'un bean implémente l'interface [ErrorController] (importée ligne 24), comme celui-ci ;
- lignes 37-40 : la clé du message de chaque code d'erreur. [static final] : un seul dictionnaire, créé au chargement de la classe et jamais remplacé ; [Map.of] le rend de plus non modifiable ;
- ligne 42 : [@RequestMapping], sans méthode HTTP, accepte toutes les méthodes : une requête POST transmise à [/error] (un patient qui tente une annulation réservée aux gestionnaires, par exemple) reste une requête POST ;
- lignes 45-46 : le code d'erreur d'origine, que le serveur a rangé dans un attribut de la requête, dont le nom est la constante [RequestDispatcher.ERROR_STATUS_CODE]. L'attribut est de type [Object] : [statut instanceof Integer c] teste qu'il s'agit bien d'un [Integer] et, si c'est le cas, le range dans la variable [c]. Sinon (adresse [/error] tapée à la main), le code est 500 ;
- lignes 49-51 : un code hors des erreurs HTTP devient une erreur 500 ;
- ligne 52 : la page garde le code d'erreur d'origine (une page inconnue reste une réponse 404) ;
- lignes 53-55 : le modèle de la page, et le nom de sa vue. [getOrDefault] rend la clé associée au code, ou [ERRORS.SERVER_ERROR] pour tout autre code (500 en particulier).
11.17. Les vues
Les vues sont écrites avec [Thymeleaf] (cf. exemples 13 à 16). Rappelons son principe : une vue est un vrai fichier HTML, enrichi d'attributs [th:...] que [Thymeleaf] remplace au moment de fabriquer la page. Ouvert directement dans un navigateur, sans serveur, un fichier s'affiche quand même, avec ses textes d'exemple (« Mme Alice DURAND », « 8:00 - 8:20 ») : on parle de templates naturels. Les attributs utilisés dans l'étude de cas sont ceux des exemples :
Attribut ou expression | Rôle |
[th:text="${modele.jour}"] | remplace le contenu de l'élément par la valeur, échappée ([<] devient [<]) |
[th:utext] | la même chose, sans échappement (seulement pour l'image du captcha) |
[#{AGENDA.FREE}] | un message traduit (cf. exemple 22) |
[#{BOOKING_DIALOG.SLOT('horaire', ${r.horaire})}] | un message avec un paramètre [{horaire}] |
[#{${erreur.cle}(${erreur.parametres})}] | un message dont la clé est elle-même une valeur, avec un dictionnaire de paramètres |
[th:if], [th:unless] | l'élément n'est écrit que si la condition est vraie (fausse) |
[th:each="ligne : ${lignes}"] | l'élément est répété pour chaque élément de la liste |
[th:value], [th:href], [th:action] | un attribut calculé |
[th:selected], [th:autofocus] | un attribut booléen : écrit si la valeur est vraie, omis sinon |
[th:classappend] | ajoute une classe CSS à celles de l'attribut [class] |
[th:attr="data-confirmation=#{...}"] | un attribut quelconque, calculé |
[th:with="etat=${modele.etat}"] | une variable locale à l'élément |
[th:replace="~{fragments/flash :: flash}"] | remplace l'élément par un fragment (cf. exemple 15) |
[<th:block>] | une balise qui ne laisse aucune trace dans la page produite |
[<!--/* ... */-->] | un commentaire, retiré de la page produite |
Les vues n'ont presque rien à décider : leur modèle a été préparé par les contrôleurs, dans les [record] du fichier [models/ModelesVues.java] (quels boutons afficher, quelles URL, quelles clés de traduction). Chaque vue commence par un commentaire [<!--/* ... */-->] qui décrit son rôle ; ces commentaires n'apparaissent pas dans les pages envoyées au navigateur. Contrairement à [Razor], [Thymeleaf] ne connaît pas le type du modèle : une faute de frappe dans [${modele.jourr}] n'est pas détectée à la compilation, mais au moment où la page est fabriquée, par une exception (et donc une page 500).
Toutes les vues ont accès aux mêmes variables : [modele], le modèle de la page, rangé par le contrôleur ; [utilisateur], [langue] et [chemin] (l'utilisateur connecté, ou [null], la langue de la requête, le chemin de l'URL demandée), ajoutés au modèle de toutes les actions par la méthode [@ModelAttribute] de [web/ModeleCommun.java] ; et [flash], le message flash éventuel : après une redirection, Spring MVC place de lui-même les attributs flash dans le modèle de la requête suivante.
11.17.1. Le gabarit et les fragments
11.17.1.1. templates/layout.html
| <!doctype html>
<!--/* =========================================================================
templates/layout.html
-------------------------------------------------------------------------
Le gabarit COMMUN à toutes les pages (cf. exemple 15) : un fragment
[page] dont le paramètre [contenu] est le bloc <main> de la page demandée
par le contrôleur (agenda.html, login.html...).
Thymeleaf ÉCHAPPE tout ce qu'il affiche avec th:text (les caractères
< > & " ' deviennent < >...) : protection contre l'injection de HTML
via un nom saisi, par exemple. Pour insérer du HTML tel quel, il faut le
demander explicitement (th:utext, cf. fragments/captcha.html).
Messages : un succès s'affiche en HAUT de la page (fragment flash), une
erreur en BAS, sur fond rose léger (fragment erreurs).
Le gabarit partage les variables de la page : [modele] (le modèle de vue
transmis par le contrôleur, cf. models/ModelesVues.java), où il lit le
titre de la page ; [langue], [utilisateur], [chemin], ajoutés à toutes
les vues par web/ModeleCommun.java ; [flash], le message flash éventuel.
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:lang="${langue}" th:fragment="page(contenu)">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--/* #{${cle}} : le message dont la clé est la VALEUR de l'expression ${cle} */-->
<title th:text="|#{${modele.titrePage}} - #{APP.TITLE}|">RdvMedecins</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="container py-4">
<nav th:replace="~{fragments/navbar :: navbar}"></nav>
<div th:replace="~{fragments/flash :: flash}"></div>
<main th:replace="${contenu}"></main>
<div th:replace="~{fragments/erreurs :: erreurs}"></div>
</div>
<!--/* la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin */-->
<div th:replace="~{fragments/confirmation :: confirmation}"></div>
<script src="/js/app.js"></script>
</body>
</html>
|
C'est le gabarit de l'exemple 15. Toute page de l'application commence par [<html th:replace="~{layout :: page(~{::main})}">] : elle est remplacée par le fragment [page] de ce gabarit, auquel elle passe son propre élément [<main>].
- lignes 2-21 : un commentaire de [Thymeleaf] : [<!--/*] et [*/-->] encadrent un texte que [Thymeleaf] retire de la page produite. Un navigateur qui ouvre le fichier sans serveur y voit un commentaire HTML ordinaire ;
- ligne 22 : [th:fragment="page(contenu)"] fait de tout l'élément [<html>] un fragment nommé [page], qui reçoit un paramètre, [contenu]. [th:lang] écrit l'attribut [lang] de la page : la langue de la requête ([fr] ou [en]) ;
- ligne 27 : le titre, traduit, par exemple « Agenda - RdvMedecins ». Les barres verticales [|...|] délimitent une substitution littérale : un texte dans lequel les expressions sont remplacées par leur valeur. [#{${modele.titrePage}}] est le message dont la clé est la valeur de [${modele.titrePage}] : ce titre est fourni par le contrôleur, dans le modèle de chaque page (à l'exemple 15, la page le passait en paramètre du fragment) ;
- lignes 28-29 : les feuilles de style, servies par [Spring Boot] depuis le dossier [src/main/resources/static] : [Bootstrap] ([static/lib/bootstrap]) et la feuille propre à l'application ;
- lignes 33-36 : la barre de navigation, le message de succès (en haut), l'élément [<main>] de la page demandée ([th:replace="${contenu}"] : l'élément est remplacé par le fragment reçu en paramètre), la zone d'erreur (en bas) ;
- lignes 39-40 : la fenêtre de confirmation, cachée, et son script (cf. exemple 21), chargé en fin de page.
Le début de la page de connexion, telle que la reçoit le navigateur :
| curl -i http://localhost:8081/login
|
| HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion - RdvMedecins</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
...
|
Les commentaires [<!--/* ... */-->] ont disparu, en laissant seulement des lignes vides ; les attributs [th:...] aussi.
11.17.1.2. templates/fragments/navbar.html
| <!--/* =========================================================================
templates/fragments/navbar.html - la barre de navigation.
Titre, FR/EN, rôle, nom, déconnexion - et un menu dont les entrées
dépendent du rôle : "Médecins" et "Clients" n'apparaissent que pour
l'ADMIN. Ce n'est qu'un confort d'affichage : la vraie protection, ce sont
les @PreAuthorize des contrôleurs (un USER qui tape /medecins à la main
obtient une page 403). Un patient (USER) a en plus son menu "Mon espace".
Le menu de la page courante est mis en évidence (classe "active") :
[chemin] est l'URL demandée (cf. web/ModeleCommun.java).
========================================================================= */-->
<nav xmlns:th="http://www.thymeleaf.org" th:fragment="navbar"
class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
<span class="navbar-brand mb-0" th:text="#{APP.TITLE}">RdvMedecins</span>
<ul class="navbar-nav me-auto" th:if="${utilisateur != null}">
<li class="nav-item" th:if="${utilisateur.estPatient()}">
<a class="nav-link" th:classappend="${#strings.startsWith(chemin, '/mon-')} ? 'active'"
href="/mon-espace" th:text="#{APP.MENU_MY_SPACE}">Mon espace</a>
</li>
<li class="nav-item">
<a class="nav-link" th:classappend="${#strings.startsWith(chemin, '/agenda')} ? 'active'"
href="/agenda" th:text="#{APP.MENU_AGENDA}">Agenda</a>
</li>
<!--/* th:block : une balise de Thymeleaf qui ne laisse aucune trace dans le HTML */-->
<th:block th:if="${utilisateur.estAdmin()}">
<li class="nav-item">
<a class="nav-link" th:classappend="${#strings.startsWith(chemin, '/medecins')} ? 'active'"
href="/medecins" th:text="#{APP.MENU_DOCTORS}">Médecins</a>
</li>
<li class="nav-item">
<a class="nav-link" th:classappend="${#strings.startsWith(chemin, '/clients')} ? 'active'"
href="/clients" th:text="#{APP.MENU_PATIENTS}">Clients</a>
</li>
</th:block>
</ul>
<div class="me-auto" th:if="${utilisateur == null}"></div>
<div class="d-flex align-items-center gap-2">
<!--/* FR / EN : de simples liens (GET /langue/fr), la langue active en surbrillance */-->
<div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
<a class="btn" th:classappend="${langue == 'fr'} ? 'btn-light' : 'btn-outline-light'" href="/langue/fr">FR</a>
<a class="btn" th:classappend="${langue == 'en'} ? 'btn-light' : 'btn-outline-light'" href="/langue/en">EN</a>
</div>
<th:block th:if="${utilisateur != null}">
<span class="badge text-bg-light text-primary" th:text="${utilisateur.role}">ADMIN</span>
<span class="text-white" th:text="${utilisateur.nom}">nom</span>
<!--/* la déconnexion est un POST : un formulaire réduit à son bouton */-->
<form method="post" action="/logout" class="m-0">
<button type="submit" class="btn btn-sm btn-outline-light" th:text="#{APP.LOGOUT}">Se déconnecter</button>
</form>
</th:block>
</div>
</nav>
|
- lignes 13-14 : le fragment [navbar], inséré par le gabarit ;
- ligne 17 : le menu n'est affiché qu'à un utilisateur connecté. Sur la page de connexion, [utilisateur] vaut [null] ;
- lignes 18-21 : « Mon espace », pour un patient seulement. [utilisateur.estPatient()] appelle une méthode du [record] [UtilisateurConnecte] : les expressions de [Thymeleaf] peuvent appeler des méthodes ;
- ligne 19 : le menu de la page courante est mis en évidence (classe [active]). [#strings.startsWith] est une fonction de l'objet utilitaire [#strings] de [Thymeleaf] (cf. exemple 14). [condition ? 'active'], sans partie « sinon », vaut [null] si la condition est fausse, et [th:classappend] n'ajoute alors rien ;
- lignes 27-36 : « Médecins » et « Clients », pour l'ADMIN seulement. [th:block] regroupe les deux éléments sous une même condition, sans laisser de balise dans la page. Ce n'est qu'un confort d'affichage : la vraie protection, ce sont les [@PreAuthorize] des contrôleurs (un USER qui tape [/medecins] à la main obtient une page 403, on l'a vu) ;
- ligne 38 : sans utilisateur connecté, un élément vide repousse les boutons FR / EN à droite de la barre, comme le fait le menu ;
- lignes 43-44 : les boutons FR / EN, de simples liens vers [/langue/fr] et [/langue/en] ; celui de la langue active est en surbrillance ([btn-light]). Ici, l'expression conditionnelle a ses deux branches ;
- lignes 48-49 : le rôle et le nom de l'utilisateur, lus dans le jeton ;
- lignes 51-53 : la déconnexion est une requête POST (cf. exemple 25), traitée par [Spring Security] (cf. [SecuriteConfig]) : un formulaire réduit à son bouton. Un simple lien (GET), placé sur un autre site, ne pourrait pas ainsi déconnecter l'utilisateur à son insu.
11.17.1.3. templates/fragments/flash.html
| <!--/* =========================================================================
templates/fragments/flash.html - le message de SUCCÈS déposé par l'action
précédente (rendez-vous enregistré, médecin ajouté...), affiché une seule
fois, en haut de la page (cf. web/CookieFlashMapManager.java).
Un message flash d'ERREUR, lui, est affiché en bas de la page, par
fragments/erreurs.html.
#{${flash.cle}(${flash.parametres})} : le message dont la clé est
flash.cle, avec en paramètre le dictionnaire des valeurs de {nom},
{login}... (cf. web/Traducteur.java).
========================================================================= */-->
<div xmlns:th="http://www.thymeleaf.org" th:fragment="flash"
th:if="${flash != null and flash.estSucces()}" class="alert alert-success" role="status"
th:text="#{${flash.cle}(${flash.parametres})}">message</div>
|
- lignes 12-14 : le message de succès déposé par l'action précédente, avant une redirection. [flash] est un [MessageFlash] (cf. [web/MessageFlash.java]) : un type (succès ou erreur), une clé de traduction et des paramètres. Ce fragment n'affiche que les succès ; les erreurs sont affichées en bas de page par le fragment suivant. [role="status"] fait annoncer le message par les lecteurs d'écran ;
- ligne 14 : [#{${flash.cle}(${flash.parametres})}] : le message dont la clé est [flash.cle], avec pour paramètres le dictionnaire [flash.parametres] (les valeurs de [{nom}], [{login}]...). C'est le service [Traducteur] (cf. « La couche web : les services communs ») qui remplace ces paramètres dans le texte.
11.17.1.4. templates/fragments/erreurs.html
| <!--/* =========================================================================
templates/fragments/erreurs.html - la zone d'erreur, en BAS de chaque
page, sur fond rose léger (classe .zone-erreur, cf. static/css/styles.css).
Elle regroupe les deux sources d'erreur possibles :
- modele.erreur : l'erreur de la page affichée (formulaire refusé, login
ou prénom + nom déjà utilisés, date invalide, mot de passe incorrect...) ;
- un message flash d'erreur, déposé par l'action précédente avant une
redirection (créneau déjà réservé, suppression refusée...).
th:with définit des variables locales à l'élément. Il est placé sur un
th:block ENGLOBANT : sur un même élément, Thymeleaf évalue th:if AVANT
th:with, et les variables n'existeraient pas encore. role="alert" : un
lecteur d'écran annonce le message dès l'affichage de la page, même s'il
se trouve en bas.
========================================================================= */-->
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="erreurs"
th:with="erreur=${modele.erreur}, flashErreur=${flash != null and !flash.estSucces()} ? ${flash}">
<div th:if="${erreur != null or flashErreur != null}" class="zone-erreur" role="alert">
<div th:if="${erreur != null}" th:text="#{${erreur.cle}(${erreur.parametres})}">erreur de la page</div>
<div th:if="${flashErreur != null}" th:text="#{${flashErreur.cle}(${flashErreur.parametres})}">erreur flash</div>
</div>
</th:block>
|
- lignes 5-9 : la zone d'erreur regroupe les deux sources d'erreur possibles : l'erreur de la page ([modele.erreur]) et un message flash d'erreur ;
- lignes 17-18 : [th:with] définit deux variables locales. [erreur] est l'erreur de la page ; [flashErreur] est le message flash s'il s'agit d'une erreur, [null] sinon (une expression conditionnelle sans « sinon », comme dans la barre de navigation). Ces variables sont définies sur un [th:block] qui englobe l'élément qui les utilise. On ne peut pas écrire [th:with] et [th:if] sur un même élément : [Thymeleaf] traite les attributs d'un élément dans un ordre fixé, qui ne dépend pas de leur ordre d'écriture, et [th:if] est évalué avant [th:with]. La condition utiliserait des variables qui n'existent pas encore ;
- ligne 19 : la zone n'est écrite que s'il y a au moins un message. [role="alert"] : un lecteur d'écran annonce le message dès l'affichage de la page, même s'il se trouve en bas ;
- lignes 20-21 : les deux messages, traduits avec leurs paramètres.
Par exemple, pour un agenda demandé avec une date invalide ([jour=2026-02-30]), le bas de la page contient :
| <div class="zone-erreur" role="alert">
<div>La date demandée n'est pas valide.</div>
</div>
|
11.17.1.5. templates/fragments/confirmation.html
| <!--/* =========================================================================
templates/fragments/confirmation.html - la fenêtre modale "Êtes-vous sûr ?".
Elle remplace la boîte window.confirm() du navigateur, qu'on ne peut ni
mettre en forme, ni traduire (ses boutons restent dans la langue du
navigateur, pas dans celle de la page).
Une SEULE fenêtre pour toute la page, cachée (d-none) au départ. Ses
libellés sont traduits ici, par le serveur, comme le reste de la page ;
le petit script static/js/app.js se contente de l'afficher, d'y placer
le message propre au formulaire (attribut data-confirmation), et d'envoyer
le formulaire si l'utilisateur confirme.
Mêmes classes Bootstrap que la fenêtre de réservation (fragments/reservation.html).
========================================================================= */-->
<div xmlns:th="http://www.thymeleaf.org" th:fragment="confirmation" id="modale-confirmation" class="d-none">
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true"
aria-labelledby="modale-confirmation-titre" aria-describedby="modale-confirmation-message">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modale-confirmation-titre" th:text="#{CONFIRMATION.TITLE}">Confirmation</h5>
<button type="button" class="btn-close" data-action="non" th:attr="aria-label=#{BOOKING_DIALOG.CLOSE_ARIA}"></button>
</div>
<div class="modal-body">
<p class="mb-0" id="modale-confirmation-message"></p>
</div>
<div class="modal-footer">
<!--/* "Non, revenir" plutôt qu'"Annuler" : la question est parfois
« Annuler ce rendez-vous ? », et un bouton "Annuler" y serait ambigu */-->
<button type="button" class="btn btn-secondary" data-action="non" th:text="#{CONFIRMATION.NO}">Non</button>
<button type="button" class="btn btn-danger" data-action="oui" th:text="#{CONFIRMATION.YES}">Oui</button>
</div>
</div>
</div>
</div>
</div>
|
- lignes 4-6 : pourquoi une fenêtre dans la page, plutôt que la boîte [window.confirm()] du navigateur (cf. exemple 21) ;
- ligne 16 : une seule fenêtre pour toute la page, cachée au départ par la classe [d-none] de [Bootstrap] ([display: none]). Le script [static/js/app.js] la trouve par son identifiant ;
- lignes 17-21 : les classes [Bootstrap] d'une fenêtre modale : le fond grisé ([modal-backdrop]), puis la fenêtre, visible ([show d-block]) dès que la classe [d-none] est retirée. [role="alertdialog"], [aria-modal], [aria-labelledby] et [aria-describedby] décrivent la fenêtre aux lecteurs d'écran ;
- ligne 24 : la croix de fermeture. Son libellé pour les lecteurs d'écran ([aria-label]) est traduit : [th:attr] calcule un attribut quelconque ;
- ligne 27 : l'emplacement du message propre à chaque formulaire, placé là par le script ;
- lignes 30-33 : « Non, revenir » plutôt qu'« Annuler » : la question est parfois « Annuler ce rendez-vous ? ». L'attribut [data-action] dit au script quel bouton a été cliqué.
11.17.1.6. templates/fragments/captcha.html
| <!--/* =========================================================================
templates/fragments/captcha.html - le captcha de la page d'authentification
(cf. auth/CaptchaService.java). Il protège à la fois la connexion et
l'accès à la création de compte (bouton "Créer un compte client").
Paramètre : captcha, un objet Captcha (id, svg).
th:utext : l'image SVG est insérée TELLE QUELLE dans la page, sans
échappement. C'est sans danger ici parce que ce SVG est fabriqué par le
serveur lui-même et ne contient rien qui vienne de l'utilisateur - on ne
ferait JAMAIS cela avec une saisie.
Le champ caché captchaId dit au serveur QUEL captcha vérifier ; la bonne
réponse, elle, n'est jamais envoyée au navigateur.
"Autre image" recharge simplement la page ([chemin] = l'URL courante).
========================================================================= */-->
<div xmlns:th="http://www.thymeleaf.org" th:fragment="captcha(captcha)" class="mb-3">
<label class="form-label" for="captcha" th:text="#{CAPTCHA.LABEL}">Recopiez les caractères</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" th:attr="aria-label=#{CAPTCHA.IMAGE_ALT}" th:utext="${captcha.svg}">image</div>
<a class="small" th:href="${chemin}" th:text="#{CAPTCHA.NEW_IMAGE}">Autre image</a>
</div>
<input type="hidden" name="captchaId" th:value="${captcha.id}">
<input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off"
spellcheck="false" required class="form-control">
<div class="form-text" th:text="#{CAPTCHA.HELP}">aide</div>
</div>
|
- ligne 16 : un fragment paramétré : la page qui l'insère lui passe l'objet [Captcha] (identifiant et image) ;
- ligne 19 : l'image SVG du captcha (cf. exemple 27) est insérée telle quelle dans la page ([th:utext]), sans échappement. C'est sans danger ici parce que ce SVG est fabriqué par le serveur lui-même et ne contient rien qui vienne de l'utilisateur ; on ne ferait jamais cela avec une saisie. [role="img"] et [aria-label] décrivent l'image aux lecteurs d'écran ;
- ligne 20 : « Autre image » recharge simplement la page ([chemin] est le chemin de l'URL courante) : chaque affichage produit un nouveau captcha ;
- ligne 22 : le champ caché [captchaId] dit au serveur quel captcha vérifier ; la bonne réponse, elle, n'est jamais envoyée au navigateur ;
- lignes 23-24 : le champ de saisie. [autocomplete="off"], [autocapitalize="off"] et [spellcheck="false"] évitent que le navigateur propose, mette en majuscule ou corrige la saisie.
11.17.1.7. templates/fragments/champs-personne.html
| <!--/* =========================================================================
templates/fragments/champs-personne.html - les champs d'identité (titre,
prénom, nom), communs à trois formulaires : médecin/client de l'ADMIN,
"Créer un compte client" et "Gérer mon compte".
Le fragment lit la variable [modele] de la page qui l'insère (un
FormulaireVue, cf. models/ModelesVues.java) : valeurs, message sous
chaque champ, champs encadrés en rouge.
th:selected : un attribut booléen. Thymeleaf écrit selected="selected"
si la valeur est vraie, et OMET l'attribut si elle est fausse.
th:classappend : ajoute une classe à celles déjà présentes - ici
"is-invalid" (encadré rouge de Bootstrap) pour un champ refusé, ou rien.
========================================================================= */-->
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="champs" th:with="etat=${modele.etat}">
<div class="mb-3">
<label class="form-label" for="titre" th:text="#{PERSONNES.TITLE_LABEL}">Titre</label>
<select id="titre" name="titre" class="form-select" th:classappend="${etat.classeInvalide('titre')}">
<option th:each="titre : ${modele.titres()}" th:value="${titre.valeur}"
th:selected="${titre.selectionne}" th:text="${titre.valeur}">Mr</option>
</select>
<div class="invalid-feedback" th:if="${etat.erreurDe('titre') != null}"
th:text="#{${etat.erreurDe('titre')}}">erreur</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label" for="prenom" th:text="#{PERSONNES.FIRSTNAME_LABEL}">Prénom</label>
<input id="prenom" name="prenom" type="text" maxlength="20" required
class="form-control" th:classappend="${etat.classeInvalide('prenom')}" th:value="${modele.valeurs.prenom}">
<div class="invalid-feedback" th:if="${etat.erreurDe('prenom') != null}"
th:text="#{${etat.erreurDe('prenom')}}">erreur</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="nom" th:text="#{PERSONNES.LASTNAME_LABEL}">Nom</label>
<input id="nom" name="nom" type="text" maxlength="20" required
class="form-control" th:classappend="${etat.classeInvalide('nom')}" th:value="${modele.valeurs.nom}">
<div class="invalid-feedback" th:if="${etat.erreurDe('nom') != null}"
th:text="#{${etat.erreurDe('nom')}}">erreur</div>
</div>
</div>
</th:block>
|
Les champs titre, prénom et nom sont communs à trois formulaires : celui de l'ADMIN (médecins et clients), la création de compte et « Gérer mon compte ». Ils sont écrits une seule fois, dans ce fragment.
- ligne 14 : le fragment [champs] est un [th:block] : il n'ajoute aucune balise autour des champs. Il lit la variable [modele] de la page qui l'insère : un modèle qui implémente l'interface [FormulaireVue] (cf. [models/ModelesVues.java]), et qui a donc des valeurs ([valeurs]) et un état ([etat]). [th:with] range l'état dans une variable plus courte ; il n'y a pas de [th:if] sur ce même élément ;
- ligne 17 : [th:classappend] ajoute la classe rendue par [etat.classeInvalide('titre')] : [is-invalid] (le cadre rouge de [Bootstrap]) pour un champ refusé, une chaîne vide sinon ;
- lignes 18-19 : les titres ([Mr], [Mme], [Melle]), rendus par la méthode [titres()] du modèle, une méthode par défaut de l'interface [FormulaireVue]. Chaque option dit si elle est sélectionnée : [th:selected] écrit [selected="selected"] si la valeur est vraie, et omet l'attribut si elle est fausse ;
- lignes 21-22 : le message d'erreur du champ, s'il y en a un. [#{${etat.erreurDe('titre')}}] : le message dont la clé est rendue par [erreurDe]. La classe [invalid-feedback] de [Bootstrap] n'affiche ce message que sous un champ [is-invalid] ;
- lignes 25-40 : le prénom et le nom, sur le même modèle, côte à côte sur un écran assez large ([col-sm-6]). [th:value] réaffiche la saisie ; [maxlength] et [required] sont un confort pour l'utilisateur, la vraie validation étant faite par le serveur.
11.17.1.8. templates/fragments/reservation.html
| <!--/* =========================================================================
templates/fragments/reservation.html - la fenêtre de réservation de
l'agenda.
Les VRAIES classes Bootstrap d'une fenêtre modale (.modal,
.modal-backdrop...), rendues visibles "en dur" (show d-block) plutôt que
par le JavaScript de Bootstrap. C'est le SERVEUR qui décide de l'afficher
(paramètre &reserver=... de l'URL), et la fermer revient à suivre un lien
vers la même page sans ce paramètre.
Le fragment lit directement la variable [modele] de la page qui l'insère
(un AgendaModele).
========================================================================= */-->
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="reservation" th:with="reservation=${modele.reservation}">
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="titre-reservation">
<div class="modal-dialog modal-dialog-centered">
<form method="post" action="/agenda/reserver" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="titre-reservation" th:text="#{BOOKING_DIALOG.TITLE}">Réserver ce créneau</h5>
<a class="btn-close" th:href="${modele.urlRetour}" th:attr="aria-label=#{BOOKING_DIALOG.CLOSE_ARIA}"></a>
</div>
<div class="modal-body">
<p th:text="#{BOOKING_DIALOG.SLOT('horaire', ${reservation.horaire})}">Créneau de 8:00 - 8:20</p>
<!--/* ce que le serveur doit savoir en plus du patient choisi */-->
<input type="hidden" name="idMedecin" th:value="${modele.idMedecin}">
<input type="hidden" name="jour" th:value="${modele.jour}">
<input type="hidden" name="idCreneau" th:value="${reservation.idCreneau}">
<!--/* un patient réserve pour lui-même : pas de liste à choisir
(et le serveur ignore de toute façon un éventuel idClient) */-->
<p class="mb-0" th:if="${reservation.patient != null}">
<span th:text="#{BOOKING_DIALOG.PATIENT_LABEL}">Patient</span> :
<strong th:text="${reservation.patient}">Mme Alice DURAND</strong>
</p>
<th:block th:if="${reservation.patient == null}">
<label class="form-label" for="idClient" th:text="#{BOOKING_DIALOG.PATIENT_LABEL}">Patient</label>
<select id="idClient" name="idClient" class="form-select" required autofocus>
<option value="" th:text="#{BOOKING_DIALOG.CHOOSE_PATIENT}">-- choisir un patient --</option>
<option th:each="client : ${reservation.clients}" th:value="${client.id}"
th:text="${client.libelle}">Mme Alice DURAND</option>
</select>
</th:block>
</div>
<div class="modal-footer">
<a class="btn btn-secondary" th:href="${modele.urlRetour}" th:text="#{BOOKING_DIALOG.CANCEL}">Annuler</a>
<button type="submit" class="btn btn-primary" th:text="#{BOOKING_DIALOG.CONFIRM}">Confirmer</button>
</div>
</form>
</div>
</div>
</th:block>
|
- lignes 5-9 : la fenêtre de réservation utilise les vraies classes d'une fenêtre modale de [Bootstrap], rendues visibles « en dur » ([show d-block]) plutôt que par le [JavaScript] de [Bootstrap]. C'est le serveur qui décide de l'afficher ;
- ligne 14 : le fragment lit le modèle de la page (un [AgendaModele]) ; [th:with] range la fenêtre ([modele.reservation]) dans la variable [reservation] ;
- ligne 18 : le formulaire envoie la réservation en POST à [/agenda/reserver] ;
- ligne 21 : la croix de fermeture est un lien vers l'agenda, sans le paramètre [reserver] : fermer la fenêtre, c'est simplement afficher la page sans elle ;
- ligne 25 : « Créneau de 8:00 - 8:20 » : un message avec un paramètre. Dans [#{BOOKING_DIALOG.SLOT('horaire', ${reservation.horaire})}], les paramètres sont donnés par couples (nom, valeur) : [{horaire}] est remplacé par la valeur de [reservation.horaire] ;
- lignes 28-30 : les champs cachés : le médecin et le jour (pour revenir au même agenda) et le créneau ;
- lignes 34-37 : un patient réserve pour lui-même : pas de liste, son nom seulement (le serveur ignore de toute façon un éventuel [idClient]) ;
- lignes 38-45 : un gestionnaire choisit le patient dans une liste, obligatoire ([required]) et qui reçoit le focus à l'ouverture de la page ([autofocus]) ;
- ligne 49 : « Annuler » est, lui aussi, un lien vers la page sans la fenêtre.
11.17.2. Les pages
11.17.2.1. templates/login.html
| <!doctype html>
<!--/* =========================================================================
templates/login.html - le formulaire de connexion (cf. AuthController).
Un <form> HTML ordinaire : le navigateur envoie lui-même login, mot de
passe et réponse au captcha en POST /login, sans JavaScript. En cas
d'échec, le serveur renvoie CETTE page, avec le login déjà saisi, un
NOUVEAU captcha et l'erreur, affichée en bas de page par le gabarit.
Toute la page est REMPLACÉE par le fragment [page] du gabarit, auquel on
passe le bloc <main> de cette page (~{::main}).
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main class="row justify-content-center" th:with="loginSaisi=${modele.login != ''}">
<div class="col-sm-8 col-md-6 col-lg-5">
<div class="card p-4">
<h5 class="card-title mb-3" th:text="#{LOGIN.TITLE}">Connexion</h5>
<form method="post" action="/login">
<div class="mb-3">
<label class="form-label" for="login" th:text="#{LOGIN.LOGIN_LABEL}">Login</label>
<!--/* th:autofocus : un attribut booléen, écrit si la valeur est vraie, omis sinon */-->
<input id="login" name="login" type="text" class="form-control" autocomplete="username"
th:value="${modele.login}" required th:autofocus="${!loginSaisi}">
</div>
<div class="mb-3">
<label class="form-label" for="password" th:text="#{LOGIN.PASSWORD_LABEL}">Mot de passe</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required th:autofocus="${loginSaisi}">
</div>
<div th:replace="~{fragments/captcha :: captcha(${modele.captcha})}"></div>
<button type="submit" class="btn btn-primary w-100" th:text="#{LOGIN.SUBMIT}">Se connecter</button>
<!--/* pas encore de compte : un patient peut en créer un lui-même.
Ce n'est PAS un simple lien : c'est un second bouton d'envoi de
CE formulaire, qui part vers une autre adresse (formaction). Le
serveur y vérifie le captcha ci-dessus avant d'ouvrir la page de
création de compte (cf. InscriptionController).
formnovalidate : le navigateur n'exige pas login et mot de passe
(inutiles pour créer un compte) ; c'est le serveur qui vérifie
le captcha. Entrée dans un champ déclenche toujours le PREMIER
bouton du formulaire, "Se connecter". */-->
<button type="submit" class="btn btn-outline-primary w-100 mt-3"
formaction="/inscription/acces" formnovalidate th:text="#{LOGIN.CREATE_ACCOUNT}">Créer un compte</button>
<div class="form-text text-center" th:text="#{LOGIN.CREATE_ACCOUNT_HELP}">aide</div>
</form>
<div class="text-muted small mt-3">
<span th:text="#{LOGIN.DEMO_ACCOUNTS}">Comptes de démonstration</span>
<ul class="mb-0 mt-1">
<li><code>admin</code> - <span th:text="#{LOGIN.DEMO_ADMIN}">ADMIN</span></li>
<li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span th:text="#{LOGIN.DEMO_DOCTOR}">DOCTOR</span></li>
<li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span th:text="#{LOGIN.DEMO_USER}">USER</span></li>
</ul>
</div>
</div>
</div>
</main>
</body>
</html>
|
- ligne 12 : la page est remplacée par le fragment [page] du gabarit, auquel elle passe son élément [<main>]. [~{::main}] désigne l'élément [<main>] du fichier courant. Seul cet élément est donc utilisé : les balises [<html>] et [<body>] de ce fichier ne servent qu'à en faire un fichier HTML complet, que l'on peut ouvrir dans un navigateur ;
- ligne 14 : après un échec, le contrôleur renvoie cette page avec le login déjà saisi ([modele.login]) ; la variable [loginSaisi] dit s'il est connu ;
- ligne 19 : un formulaire HTML ordinaire : le navigateur envoie lui-même le login, le mot de passe et la réponse au captcha ;
- lignes 23-29 : [th:autofocus] écrit l'attribut booléen [autofocus] si la valeur est vraie, et l'omet sinon : un seul des deux champs le reçoit. Le curseur est placé dans le champ login, ou dans le champ mot de passe si le login est déjà connu. Les valeurs de [autocomplete] ([username], [current-password]) aident les gestionnaires de mots de passe des navigateurs ;
- ligne 32 : le captcha, fragment paramétré, qui reçoit l'objet [modele.captcha] ;
- lignes 45-46 : le second bouton envoie le même formulaire à [/inscription/acces] (attribut [formaction], cf. exemple 21) : le serveur y vérifie le captcha avant d'ouvrir la page de création de compte. [formnovalidate] : le navigateur n'exige pas le login et le mot de passe, inutiles pour créer un compte. La touche Entrée, dans un champ, déclenche toujours le premier bouton du formulaire : « Se connecter » ;
- lignes 50-57 : les comptes de démonstration (mot de passe = login).
Voici le formulaire produit pour l'adresse [/login?login=martin] (l'image du captcha est abrégée) :
| curl http://localhost:8081/login?login=martin
|
| <main class="row justify-content-center">
<div class="col-sm-8 col-md-6 col-lg-5">
<div class="card p-4">
<h5 class="card-title mb-3">Connexion</h5>
<form method="post" action="/login">
<div class="mb-3">
<label class="form-label" for="login">Login</label>
<input id="login" name="login" type="text" class="form-control" autocomplete="username"
value="martin" required>
</div>
<div class="mb-3">
<label class="form-label" for="password">Mot de passe</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required autofocus="autofocus">
</div>
<div class="mb-3">
<label class="form-label" for="captcha">Recopiez les caractères de l'image</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" aria-label="Image de vérification anti-robots"><svg xmlns="http://www.w3.org/2000/svg" width="150" height="50" ...</svg></div>
<a class="small" href="/login">Autre image</a>
</div>
<input type="hidden" name="captchaId" value="b424d2c7-fd47-4527-b22e-3e30d63faa93">
<input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off"
spellcheck="false" required class="form-control">
<div class="form-text">Majuscules ou minuscules, peu importe.</div>
</div>
<button type="submit" class="btn btn-primary w-100">Se connecter</button>
|
Le login étant connu, seul le champ du mot de passe a reçu l'attribut [autofocus]. Le lien « Autre image » pointe sur [/login] : la variable [chemin] ne contient que le chemin de l'URL, sans ses paramètres.
11.17.2.2. templates/agenda.html
| <!doctype html>
<!--/* =========================================================================
templates/agenda.html - la page principale (cf. AgendaController).
Trois blocs :
1. le choix du médecin et du jour ;
2. l'agenda ;
3. la fenêtre de réservation (fragments/reservation.html).
Une éventuelle erreur (date invalide, médecin inconnu) est affichée en
bas de la page par le gabarit (cf. fragments/erreurs.html).
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<!--/* 1. choix du médecin et du jour : un formulaire GET, qui recharge
simplement cette page avec ?idMedecin=...&jour=... dans l'URL */-->
<form method="get" action="/agenda" class="card p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-sm-5">
<label class="form-label" for="idMedecin" th:text="#{DOCTOR_DAY_PICKER.DOCTOR_LABEL}">Médecin</label>
<select id="idMedecin" name="idMedecin" class="form-select" required>
<option value="" th:text="#{DOCTOR_DAY_PICKER.CHOOSE_DOCTOR}">-- choisir --</option>
<option th:each="medecin : ${modele.medecins}" th:value="${medecin.id}"
th:selected="${medecin.selectionne}" th:text="${medecin.libelle}">Mme Marie PELISSIER</option>
</select>
</div>
<div class="col-sm-4">
<label class="form-label" for="jour" th:text="#{DOCTOR_DAY_PICKER.DAY_LABEL}">Jour</label>
<input id="jour" name="jour" type="date" class="form-control" th:value="${modele.jour}" required>
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-primary w-100" th:text="#{DOCTOR_DAY_PICKER.VIEW_AGENDA}">Voir l'agenda</button>
</div>
</div>
</form>
<!--/* patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" */-->
<div class="alert alert-info" th:if="${modele.info != null}">
<span th:text="#{${modele.info}}">info</span>
<a href="/mon-espace" class="alert-link" th:text="#{APP.MENU_MY_SPACE}">Mon espace</a>
</div>
<!--/* 2. l'agenda ; th:object : ${agenda} peut ensuite s'écrire *{...} */-->
<div class="card p-3" th:if="${modele.agenda != null}" th:object="${modele.agenda}">
<h5 th:text="|#{AGENDA.TITLE_PREFIX} *{jour} - *{medecin}|">Agenda du ...</h5>
<table class="table table-hover align-middle" th:unless="*{lignes.isEmpty()}">
<thead>
<tr>
<th th:text="#{AGENDA.COLUMN_SLOT}">Créneau</th>
<th th:text="#{AGENDA.COLUMN_STATUS}">Statut</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="ligne : *{lignes}" th:classappend="${ligne.reservable} ? 'creneau-libre'">
<td th:text="${ligne.horaire}">8:00 - 8:20</td>
<td>
<!--/* [patient] n'est renseigné que si l'utilisateur a le droit de
voir ce nom (ADMIN, DOCTOR, ou le patient pour SON
rendez-vous) - sinon, simplement « Réservé » */-->
<span th:if="${ligne.libre}" class="badge text-bg-success" th:text="#{AGENDA.FREE}">Libre</span>
<span th:if="${!ligne.libre and ligne.patient != null}" class="badge"
th:classappend="${ligne.moi} ? 'text-bg-primary' : 'text-bg-secondary'"
th:text="${ligne.patient}">Mme Alice DURAND</span>
<span th:if="${!ligne.libre and ligne.patient == null}" class="badge text-bg-secondary"
th:text="#{AGENDA.BOOKED_BY_OTHER}">Réservé</span>
</td>
<td>
<!--/* les boutons affichés ont été décidés par le contrôleur
(reservable, idRv) selon le rôle de l'utilisateur */-->
<!--/* "Réserver" est un simple lien : il rouvre cette page
avec la fenêtre de réservation de ce créneau */-->
<a th:if="${ligne.reservable}" class="btn btn-sm btn-outline-success"
th:href="${ligne.urlReserver}" th:text="#{AGENDA.BOOK}">Réserver</a>
<!--/* "Annuler" MODIFIE des données : c'est donc un POST */-->
<form th:if="${ligne.idRv != null}" method="post" action="/agenda/annuler" class="m-0"
th:attr="data-confirmation=#{AGENDA.CANCEL_CONFIRM}">
<input type="hidden" name="idRv" th:value="${ligne.idRv}">
<input type="hidden" name="idMedecin" th:value="${modele.idMedecin}">
<input type="hidden" name="jour" th:value="${modele.jour}">
<button type="submit" class="btn btn-sm btn-outline-danger" th:text="#{AGENDA.CANCEL_APPOINTMENT}">Annuler</button>
</form>
</td>
</tr>
</tbody>
</table>
<p class="text-muted mb-0" th:if="*{lignes.isEmpty()}" th:text="#{AGENDA.NO_SLOTS}">Aucun créneau.</p>
</div>
<p class="text-muted" th:if="${modele.agenda == null}" th:text="#{AGENDA.EMPTY_STATE}">Choisissez un médecin...</p>
<!--/* 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... */-->
<th:block th:if="${modele.reservation != null}">
<div th:replace="~{fragments/reservation :: reservation}"></div>
</th:block>
</main>
</body>
</html>
|
Commentons ce code :
- lignes 17-35 : le choix du médecin et du jour : un formulaire GET (cf. exemple 17), qui recharge la page avec [?idMedecin=...&jour=...] dans l'URL. Chaque médecin est un [OptionVue] qui dit s'il est sélectionné ([th:selected]). Un champ [type="date"] envoie la date au format [aaaa-mm-jj] (cf. exemple 18) ;
- lignes 38-41 : le message d'un patient qui a déjà un rendez-vous à venir : il explique l'absence de boutons Réserver, et propose le lien vers « Mon espace ». Le contrôleur ne l'envoie pas quand le rendez-vous du patient figure dans l'agenda affiché (cf. [infoPatient]). [#{${modele.info}}] : la clé du message est fournie par le contrôleur ;
- ligne 44 : l'agenda, s'il a été demandé. [th:object] désigne un objet de sélection : à l'intérieur de l'élément, [*{jour}] signifie [${modele.agenda.jour}] ;
- ligne 45 : le titre : « Agenda du vendredi 25 septembre 2026 - Mme Marie PELISSIER », par une substitution littérale ;
- ligne 47 : le tableau n'est écrit que si l'agenda a des lignes ([th:unless] : sauf si...) ;
- ligne 56 : une ligne par créneau. Un créneau réservable change de couleur au survol (classe [creneau-libre], cf. [styles.css]) ;
- lignes 62-67 : trois cas : libre ; occupé, avec le nom du patient (en bleu si c'est le rendez-vous du patient connecté, [moi] ; en gris sinon) ; occupé sans nom visible, « Réservé ». Le contrôleur ne renseigne [patient] que si l'utilisateur a le droit de voir ce nom ;
- lignes 74-75 : « Réserver » est un lien : il rouvre cette page, avec la fenêtre de réservation de ce créneau ([urlReserver], préparée par le contrôleur) ;
- lignes 77-83 : « Annuler » modifie des données : c'est un formulaire POST, avec confirmation. [th:attr] écrit l'attribut [data-confirmation], qui porte la question traduite ; le script [app.js] intercepte l'envoi des formulaires qui ont cet attribut (cf. exemple 21). Les champs cachés [idMedecin] et [jour] permettent au contrôleur de rediriger vers le même agenda. Dans la boucle, [modele] désigne toujours le modèle de la page ;
- lignes 88 et 90 : un médecin sans créneaux, ou aucun agenda encore demandé ;
- lignes 93-95 : la fenêtre de réservation, si le contrôleur l'a préparée. La condition est posée sur un [th:block] englobant : le fragment, qui lit [modele.reservation], n'est inséré que si cette fenêtre existe.
11.17.2.3. templates/personnes/liste.html
| <!doctype html>
<!--/* =========================================================================
templates/personnes/liste.html - la liste des médecins OU des clients (ADMIN).
Une seule vue pour les deux : les libellés propres à chacun arrivent dans
modele.cles (cf. MedecinsController / ClientsController).
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0" th:text="#{${modele.cles.titre}}">Médecins</h4>
<a class="btn btn-primary" th:href="${modele.urlAjouter}" th:text="#{${modele.cles.ajouter}}">Ajouter</a>
</div>
<div class="card p-3">
<table class="table table-hover align-middle mb-0" th:unless="${modele.personnes.isEmpty()}">
<thead>
<tr>
<th th:text="#{PERSONNES.COLUMN_TITLE}">Titre</th>
<th th:text="#{PERSONNES.COLUMN_FIRSTNAME}">Prénom</th>
<th th:text="#{PERSONNES.COLUMN_LASTNAME}">Nom</th>
<th th:text="#{PERSONNES.COLUMN_LOGIN}">Login</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="personne : ${modele.personnes}">
<td th:text="${personne.titre}">Mme</td>
<td th:text="${personne.prenom}">Marie</td>
<td th:text="${personne.nom}">PELISSIER</td>
<td><code th:text="${personne.login}">pelissier</code></td>
<td class="text-end">
<div class="d-inline-flex gap-2">
<a class="btn btn-sm btn-outline-primary" th:href="${personne.urlModifier}"
th:text="#{PERSONNES.EDIT}">Modifier</a>
<!--/* data-confirmation : une fenêtre de confirmation s'ouvrira
avant l'envoi du formulaire (cf. static/js/app.js) */-->
<form method="post" th:action="${personne.urlSupprimer}" class="m-0"
th:attr="data-confirmation=#{${modele.cles.confirmerSuppression}}">
<button type="submit" class="btn btn-sm btn-outline-danger" th:text="#{PERSONNES.DELETE}">Supprimer</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
<p class="text-muted mb-0" th:if="${modele.personnes.isEmpty()}" th:text="#{${modele.cles.vide}}">Aucun.</p>
</div>
</main>
</body>
</html>
|
- lignes 11-12 : le titre et le libellé du bouton d'ajout : des clés de traduction fournies par le contrôleur ([modele.cles]), qui diffèrent pour les médecins et pour les clients ;
- lignes 16 et 47 : le tableau, ou le message « Aucun médecin. » (« Aucun client. ») si la liste est vide ;
- ligne 27 : une ligne par personne ;
- ligne 31 : le login, en police à chasse fixe ([<code>]) ;
- lignes 34-35 : « Modifier » est un lien vers le formulaire de modification ;
- lignes 38-41 : « Supprimer » modifie des données : c'est un formulaire POST, avec confirmation. La question de confirmation est une clé fournie par le contrôleur : [#{${modele.cles.confirmerSuppression}}] ; son adresse ([th:action]) aussi.
| <!doctype html>
<!--/* =========================================================================
templates/personnes/formulaire.html - création OU modification d'un
médecin ou d'un client (ADMIN). modele.creation distingue les deux cas.
Quand la saisie est refusée (champ vide, login déjà pris...), le
contrôleur renvoie CETTE vue avec :
- les valeurs déjà saisies (modele.valeurs) ;
- les champs fautifs encadrés en rouge (classe Bootstrap "is-invalid") ;
- sous un champ refusé par la validation, son message ;
- le message d'erreur de la page, affiché EN BAS de la page par le
gabarit (cf. fragments/erreurs.html).
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main class="row justify-content-center" th:with="etat=${modele.etat}">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-3" th:text="#{${modele.titrePage}}">Ajouter un médecin</h5>
<!--/* novalidate : on laisse le SERVEUR valider (c'est lui qui fait foi) ;
les contrôles du navigateur (required...) ne sont qu'un confort,
qu'un utilisateur peut toujours contourner */-->
<form method="post" th:action="${modele.action}" novalidate>
<input type="hidden" name="version" th:unless="${modele.creation}" th:value="${modele.valeurs.version}">
<!--/* titre, prénom, nom : fragment commun à trois formulaires */-->
<th:block th:replace="~{fragments/champs-personne :: champs}"></th:block>
<h6 class="mt-4 text-muted" th:text="#{PERSONNES.ACCOUNT_SECTION}">Compte utilisateur</h6>
<div class="mb-3">
<label class="form-label" for="login" th:text="#{PERSONNES.LOGIN_LABEL}">Login</label>
<input id="login" name="login" type="text" maxlength="30" autocomplete="off"
class="form-control" th:classappend="${etat.classeInvalide('login')}" th:value="${modele.valeurs.login}">
<div class="invalid-feedback" th:if="${etat.erreurDe('login') != null}"
th:text="#{${etat.erreurDe('login')}}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('login') == null}"
th:text="${modele.creation} ? #{PERSONNES.LOGIN_HELP_CREATE} : #{PERSONNES.LOGIN_HELP_EDIT}">aide</div>
</div>
<div class="mb-4">
<label class="form-label" for="password" th:text="#{PERSONNES.PASSWORD_LABEL}">Mot de passe</label>
<!--/* jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur */-->
<input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
class="form-control" th:classappend="${etat.classeInvalide('password')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('password') != null}"
th:text="#{${etat.erreurDe('password')}}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('password') == null}"
th:text="${modele.creation} ? #{PERSONNES.PASSWORD_HELP_CREATE} : #{PERSONNES.PASSWORD_HELP_EDIT}">aide</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" th:text="#{PERSONNES.SAVE}">Enregistrer</button>
<a class="btn btn-secondary" th:href="${modele.urlListe}" th:text="#{PERSONNES.BACK}">Retour</a>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
|
- ligne 16 : [etat] : l'état du formulaire, pour la partie qui n'est pas dans le fragment commun ;
- ligne 19 : le titre : « Ajouter un médecin », « Modifier un client »... ;
- ligne 24 : l'adresse d'envoi, fournie par le contrôleur. [novalidate] : le navigateur ne vérifie pas lui-même les champs ([required], [maxlength]) ; c'est le serveur qui valide, et qui fait foi (cf. exemple 19) ;
- ligne 25 : la version de la fiche (verrou optimiste), seulement pour une modification ;
- ligne 28 : les champs titre, prénom et nom, par le fragment commun. [th:replace] sur un [th:block] : le fragment prend la place du bloc ;
- lignes 34-39 : le login, avec son message d'erreur, ou à défaut son aide, qui dépend du mode : [${modele.creation} ? #{...} : #{...}] choisit entre deux messages ;
- lignes 45-50 : le mot de passe, jamais prérempli : il ne repart jamais vers le navigateur ;
- ligne 55 : le retour à la liste.
11.17.2.5. templates/inscription.html
| <!doctype html>
<!--/* =========================================================================
templates/inscription.html - "Créer un compte client" (page publique,
cf. InscriptionController). Mêmes champs d'identité que le formulaire de
l'ADMIN (fragment champs-personne), mais le login et le mot de passe
sont obligatoires, avec confirmation.
Pas de captcha ICI : on n'arrive sur cette page qu'après avoir recopié
celui de la page d'authentification (bouton "Créer un compte client").
#{${cle}('min', ${modele.motDePasseMin()})} : le message dont la clé est
la valeur de [cle], avec le paramètre {min} (cf. web/Traducteur.java).
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main class="row justify-content-center" th:with="etat=${modele.etat}">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-1" th:text="#{INSCRIPTION.TITLE}">Créer un compte client</h5>
<p class="text-muted small mb-3" th:text="#{INSCRIPTION.INTRO}">intro</p>
<form method="post" action="/inscription" novalidate>
<th:block th:replace="~{fragments/champs-personne :: champs}"></th:block>
<h6 class="mt-4 text-muted" th:text="#{PERSONNES.ACCOUNT_SECTION}">Compte utilisateur</h6>
<div class="mb-3">
<label class="form-label" for="login" th:text="#{PERSONNES.LOGIN_LABEL}">Login</label>
<input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
class="form-control" th:classappend="${etat.classeInvalide('login')}" th:value="${modele.valeurs.login}">
<div class="invalid-feedback" th:if="${etat.erreurDe('login') != null}"
th:text="#{${etat.erreurDe('login')}}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('login') == null}" th:text="#{INSCRIPTION.LOGIN_HELP}">aide</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label" for="password" th:text="#{PERSONNES.PASSWORD_LABEL}">Mot de passe</label>
<input id="password" name="password" type="password" maxlength="72" autocomplete="new-password" required
class="form-control" th:classappend="${etat.classeInvalide('password')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('password') != null}"
th:text="#{${etat.erreurDe('password')}('min', ${modele.motDePasseMin()})}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('password') == null}"
th:text="#{INSCRIPTION.PASSWORD_HELP('min', ${modele.motDePasseMin()})}">aide</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="confirmation" th:text="#{COMPTE.CONFIRMATION_LABEL}">Confirmation</label>
<input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password" required
class="form-control" th:classappend="${etat.classeInvalide('confirmation')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('confirmation') != null}"
th:text="#{${etat.erreurDe('confirmation')}}">erreur</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" th:text="#{INSCRIPTION.SUBMIT}">Créer mon compte</button>
<a class="btn btn-secondary" href="/login" th:text="#{INSCRIPTION.BACK_TO_LOGIN}">Retour</a>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
|
- ligne 21 : le formulaire, envoyé en POST à [/inscription], validé par le serveur ([novalidate]) ;
- ligne 22 : les champs d'identité, communs avec le formulaire de l'ADMIN ;
- lignes 26-33 : le login, obligatoire ici ;
- lignes 37-43 : le mot de passe. Le message d'erreur « Le mot de passe doit comporter au moins {min} caractères » a un paramètre : [#{${cle}('min', ${modele.motDePasseMin()})}] passe à la fois une clé calculée et un paramètre, la longueur minimale fixée dans [models/ReglesSaisie.java], lue par une méthode du modèle ;
- lignes 46-50 : la confirmation du mot de passe, comparée par le contrôleur ;
- ligne 56 : le retour à la page de connexion.
11.17.2.6. templates/espace.html
| <!doctype html>
<!--/* =========================================================================
templates/espace.html - "Mon espace" : la page d'accueil du patient
(rôle USER), affichée juste après sa connexion.
- deux options : [Gérer mon compte] et [Voir un agenda] ;
- son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<h4 class="mb-3" th:text="#{ESPACE.HELLO('nom', ${modele.patient})}">Bonjour Mme Alice DURAND</h4>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="/mon-compte">
<h5 class="mb-1" th:text="#{ESPACE.MANAGE_ACCOUNT}">Gérer mon compte</h5>
<span class="text-muted" th:text="#{ESPACE.MANAGE_ACCOUNT_HELP}">aide</span>
</a>
</div>
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="/agenda">
<h5 class="mb-1" th:text="#{ESPACE.VIEW_AGENDA}">Voir un agenda</h5>
<span class="text-muted" th:text="#{ESPACE.VIEW_AGENDA_HELP}">aide</span>
</a>
</div>
</div>
<div class="card p-3">
<h5 th:text="#{ESPACE.MY_APPOINTMENT}">Mon rendez-vous</h5>
<!--/* normalement un seul (règle "un rendez-vous à venir à la fois"),
mais la page sait en afficher plusieurs. [etat] : une variable
d'itération fournie par th:each (etat.last : dernier élément ?) */-->
<div th:each="rv, etat : ${modele.rendezVous}"
class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2"
th:classappend="${etat.last} ? '' : 'border-bottom'">
<div>
<div class="fw-semibold" th:text="|${rv.jour}, ${rv.horaire}|">mercredi 23 septembre 2026, 8:00 - 8:20</div>
<div class="text-muted">
<span th:text="${rv.medecin}">Mme Marie PELISSIER</span> -
<a th:href="${rv.urlAgenda}" th:text="#{ESPACE.SEE_IN_AGENDA}">voir dans l'agenda</a>
</div>
</div>
<form method="post" action="/mon-espace/annuler" class="m-0"
th:attr="data-confirmation=#{AGENDA.CANCEL_CONFIRM}">
<input type="hidden" name="idRv" th:value="${rv.id}">
<button type="submit" class="btn btn-outline-danger" th:text="#{ESPACE.CANCEL_APPOINTMENT}">Annuler</button>
</form>
</div>
<p class="text-muted mb-0" th:if="${modele.rendezVous.isEmpty()}" th:text="#{ESPACE.NO_APPOINTMENT}">Aucun rendez-vous.</p>
</div>
</main>
</body>
</html>
|
- ligne 11 : « Bonjour Mr Jean MARTIN » : un message avec un paramètre [{nom}] ;
- lignes 13-26 : les deux options de la page, de grandes cartes cliquables (des liens), qui réagissent au survol (classe [carte-option], cf. [styles.css]) ;
- lignes 33-35 : le rendez-vous à venir. Il n'y en a normalement qu'un (un seul rendez-vous à venir par patient), mais la page sait en afficher plusieurs. [th:each="rv, etat : ..."] déclare, après la variable de boucle, une variable d'état fournie par [Thymeleaf] : [etat.last] dit si l'élément est le dernier de la liste ; tous les autres reçoivent un trait de séparation ([border-bottom]) ;
- ligne 37 : la date et l'horaire, déjà formatés par le contrôleur ;
- ligne 40 : le lien vers l'agenda du médecin ce jour-là, où le patient voit son nom ;
- lignes 43-47 : l'annulation, un formulaire POST avec confirmation ;
- ligne 49 : aucun rendez-vous à venir.
11.17.2.7. templates/compte.html
| <!doctype html>
<!--/* =========================================================================
templates/compte.html - "Gérer mon compte" (rôle USER).
Deux formulaires indépendants sur la même page :
1. la modification (identité, login, nouveau mot de passe facultatif) ;
2. la suppression du compte - qui annule aussi ses rendez-vous.
L'un comme l'autre exigent le mot de passe ACTUEL.
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main class="row justify-content-center" th:with="etat=${modele.etat}">
<div class="col-md-8 col-lg-6">
<!--/* 1. modification */-->
<div class="card p-4 mb-4">
<h5 class="card-title mb-3" th:text="#{MON_COMPTE.TITLE}">Gérer mon compte</h5>
<form method="post" action="/mon-compte" novalidate>
<input type="hidden" name="version" th:value="${modele.valeurs.version}">
<th:block th:replace="~{fragments/champs-personne :: champs}"></th:block>
<h6 class="mt-4 text-muted" th:text="#{PERSONNES.ACCOUNT_SECTION}">Compte utilisateur</h6>
<div class="mb-3">
<label class="form-label" for="login" th:text="#{PERSONNES.LOGIN_LABEL}">Login</label>
<input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
class="form-control" th:classappend="${etat.classeInvalide('login')}" th:value="${modele.valeurs.login}">
<div class="invalid-feedback" th:if="${etat.erreurDe('login') != null}"
th:text="#{${etat.erreurDe('login')}}">erreur</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label" for="password" th:text="#{MON_COMPTE.NEW_PASSWORD_LABEL}">Nouveau mot de passe</label>
<input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
class="form-control" th:classappend="${etat.classeInvalide('password')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('password') != null}"
th:text="#{${etat.erreurDe('password')}('min', ${modele.motDePasseMin()})}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('password') == null}"
th:text="#{PERSONNES.PASSWORD_HELP_EDIT}">aide</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="confirmation" th:text="#{COMPTE.CONFIRMATION_LABEL}">Confirmation</label>
<input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password"
class="form-control" th:classappend="${etat.classeInvalide('confirmation')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('confirmation') != null}"
th:text="#{${etat.erreurDe('confirmation')}}">erreur</div>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="motDePasseActuel" th:text="#{MON_COMPTE.CURRENT_PASSWORD_LABEL}">Mot de passe actuel</label>
<input id="motDePasseActuel" name="motDePasseActuel" type="password" maxlength="72"
autocomplete="current-password" required
class="form-control" th:classappend="${etat.classeInvalide('motDePasseActuel')}">
<div class="invalid-feedback" th:if="${etat.erreurDe('motDePasseActuel') != null}"
th:text="#{${etat.erreurDe('motDePasseActuel')}}">erreur</div>
<div class="form-text" th:if="${etat.erreurDe('motDePasseActuel') == null}"
th:text="#{MON_COMPTE.CURRENT_PASSWORD_HELP}">aide</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" th:text="#{PERSONNES.SAVE}">Enregistrer</button>
<a class="btn btn-secondary" href="/mon-espace" th:text="#{MON_COMPTE.BACK}">Retour</a>
</div>
</form>
</div>
<!--/* 2. suppression */-->
<div class="card p-4 border-danger">
<h5 class="card-title text-danger mb-1" th:text="#{MON_COMPTE.DELETE_TITLE}">Supprimer mon compte</h5>
<p class="text-muted small" th:text="#{MON_COMPTE.DELETE_HELP}">aide</p>
<form method="post" action="/mon-compte/supprimer" class="d-flex flex-wrap gap-2 align-items-end"
th:attr="data-confirmation=#{MON_COMPTE.DELETE_CONFIRM}">
<div class="flex-grow-1">
<label class="form-label" for="motDePasseSuppression" th:text="#{MON_COMPTE.CURRENT_PASSWORD_LABEL}">Mot de passe actuel</label>
<input id="motDePasseSuppression" name="motDePasseSuppression" type="password" maxlength="72"
autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-danger" th:text="#{MON_COMPTE.DELETE_SUBMIT}">Supprimer mon compte</button>
</form>
</div>
</div>
</main>
</body>
</html>
|
- lignes 15-68 : la modification du compte : identité, login, nouveau mot de passe (facultatif) et sa confirmation ;
- ligne 19 : la version de la fiche du client (verrou optimiste) ;
- lignes 52-61 : le mot de passe actuel, exigé pour toute modification ;
- lignes 71-83 : la suppression, avec confirmation. Deux formulaires indépendants sur une même page : chacun n'envoie que ses propres champs. Le champ du mot de passe a donc ici un autre nom ([motDePasseSuppression]) et un autre identifiant. Ce formulaire n'a pas l'attribut [novalidate] : le navigateur exige le mot de passe ([required]) avant même de déclencher l'événement [submit], donc avant de poser la question de confirmation.
11.17.2.8. templates/erreur.html
| <!doctype html>
<!--/* =========================================================================
templates/erreur.html - la page d'erreur (400, 403, 404, 500...),
fabriquée par ErreurController.
========================================================================= */-->
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4 text-center">
<h1 class="display-4 text-danger" th:text="${modele.code}">404</h1>
<p class="lead" th:text="#{${modele.message}}">Cette page n'existe pas.</p>
<div><a class="btn btn-primary" href="/" th:text="#{ERROR_PAGE.BACK}">Retour à l'accueil</a></div>
</div>
</div>
</main>
</body>
</html>
|
- lignes 11-12 : la page d'erreur fabriquée par [ErreurController] (400, 403, 404, 500) : le code HTTP, et le message traduit. Elle utilise le gabarit comme les autres pages : l'utilisateur garde sa barre de navigation.
11.18. Les dictionnaires
Les dictionnaires sont ceux de la version [ASP.NET Core MVC], sans changement : ils sont rangés dans [src/main/resources/i18n] et lus au démarrage par le service [Traducteur] (cf. « La couche web : les services communs »). Celui-ci « aplatit » leur structure : le texte ["TITLE"] de la section ["LOGIN"] devient le message de clé [LOGIN.TITLE], celle qu'utilisent les vues ([#{LOGIN.TITLE}]) et les contrôleurs.
11.18.1. i18n/fr.json
| {
"APP": {
"TITLE": "RdvMedecins",
"LOGOUT": "Se déconnecter",
"MENU_AGENDA": "Agenda",
"MENU_DOCTORS": "Médecins",
"MENU_PATIENTS": "Clients",
"MENU_MY_SPACE": "Mon espace"
},
"LOGIN": {
"TITLE": "Connexion",
"LOGIN_LABEL": "Login",
"PASSWORD_LABEL": "Mot de passe",
"SUBMIT": "Se connecter",
"ERROR": "Login ou mot de passe incorrect.",
"DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
"DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
"DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
"DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
"CREATE_ACCOUNT": "Créer un compte client",
"CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
},
"DOCTOR_DAY_PICKER": {
"DOCTOR_LABEL": "Médecin",
"CHOOSE_DOCTOR": "-- choisir un médecin --",
"DAY_LABEL": "Jour",
"VIEW_AGENDA": "Voir l'agenda"
},
"AGENDA": {
"PAGE_TITLE": "Agenda",
"TITLE_PREFIX": "Agenda du",
"COLUMN_SLOT": "Créneau",
"COLUMN_STATUS": "Statut",
"FREE": "Libre",
"BOOK": "Réserver",
"CANCEL_APPOINTMENT": "Annuler",
"CANCEL_CONFIRM": "Annuler ce rendez-vous ?",
"EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda ».",
"NO_SLOTS": "Ce médecin n'a aucun créneau horaire.",
"BOOKED": "Rendez-vous enregistré.",
"CANCELLED": "Rendez-vous annulé.",
"BOOKED_BY_OTHER": "Réservé",
"ALREADY_BOOKED_INFO": "Vous avez déjà un rendez-vous à venir : annulez-le pour en prendre un autre."
},
"BOOKING_DIALOG": {
"TITLE": "Réserver ce créneau",
"CLOSE_ARIA": "Fermer",
"SLOT": "Créneau de {horaire}",
"PATIENT_LABEL": "Patient",
"CHOOSE_PATIENT": "-- choisir un patient --",
"CANCEL": "Annuler",
"CONFIRM": "Confirmer"
},
"PERSONNES": {
"COLUMN_TITLE": "Titre",
"COLUMN_FIRSTNAME": "Prénom",
"COLUMN_LASTNAME": "Nom",
"COLUMN_LOGIN": "Login",
"EDIT": "Modifier",
"DELETE": "Supprimer",
"SAVE": "Enregistrer",
"BACK": "Retour à la liste",
"TITLE_LABEL": "Titre",
"FIRSTNAME_LABEL": "Prénom",
"LASTNAME_LABEL": "Nom",
"LOGIN_LABEL": "Login",
"PASSWORD_LABEL": "Mot de passe",
"LOGIN_HELP_CREATE": "Facultatif : par défaut, le nom en minuscules.",
"LOGIN_HELP_EDIT": "Laisser vide pour ne pas le changer.",
"PASSWORD_HELP_CREATE": "Facultatif : par défaut, identique au login.",
"PASSWORD_HELP_EDIT": "Laisser vide pour ne pas le changer.",
"ACCOUNT_SECTION": "Compte utilisateur"
},
"MEDECINS": {
"TITLE": "Médecins",
"ADD": "Ajouter un médecin",
"EDIT": "Modifier un médecin",
"EMPTY": "Aucun médecin.",
"DELETE_CONFIRM": "Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?",
"ADDED": "Le médecin {nom} a été ajouté (login : {login}).",
"UPDATED": "Le médecin {nom} a été modifié.",
"DELETED": "Le médecin {nom} a été supprimé."
},
"CLIENTS": {
"TITLE": "Clients",
"ADD": "Ajouter un client",
"EDIT": "Modifier un client",
"EMPTY": "Aucun client.",
"DELETE_CONFIRM": "Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?",
"ADDED": "Le client {nom} a été ajouté (login : {login}).",
"UPDATED": "Le client {nom} a été modifié.",
"DELETED": "Le client {nom} a été supprimé."
},
"ERROR_PAGE": {
"TITLE": "Erreur",
"BACK": "Retour à l'accueil"
},
"ERRORS": {
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"DOCTOR_ALREADY_EXISTS": "Un médecin portant ce prénom et ce nom existe déjà.",
"PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
"DOCTOR_HAS_APPOINTMENTS": "Ce médecin a des rendez-vous : annulez-les avant de le supprimer.",
"SLOT_ALREADY_BOOKED": "Ce créneau vient d'être réservé par quelqu'un d'autre.",
"CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Revenez à la liste et rouvrez-la avant de recommencer.",
"DOCTOR_NOT_FOUND": "Ce médecin n'existe pas (ou plus).",
"PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
"SLOT_NOT_FOUND": "Ce créneau n'existe pas (ou plus).",
"APPOINTMENT_NOT_FOUND": "Ce rendez-vous n'existe pas (ou plus).",
"INVALID_DATE": "La date demandée n'est pas valide.",
"CHOOSE_PATIENT": "Choisissez un patient.",
"INVALID_FORM": "Le formulaire contient des erreurs.",
"INVALID_TITLE": "Choisissez un titre dans la liste.",
"FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
"FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
"LASTNAME_REQUIRED": "Le nom est obligatoire.",
"LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
"INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
"PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
"SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
"BAD_REQUEST": "La requête est invalide.",
"FORBIDDEN": "Votre rôle ne vous permet pas d'accéder à cette page.",
"NOT_FOUND": "Cette page n'existe pas.",
"SERVER_ERROR": "Une erreur inattendue s'est produite. Réessayez plus tard.",
"TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {minutes} minute(s).",
"WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.",
"TOO_MANY_ACCOUNTS": "Trop de comptes ont été créés depuis votre connexion. Réessayez dans {minutes} minute(s).",
"LOGIN_REQUIRED": "Le login est obligatoire.",
"PASSWORD_REQUIRED": "Le mot de passe est obligatoire.",
"PASSWORD_TOO_SHORT": "Le mot de passe doit comporter au moins {min} caractères.",
"CONFIRMATION_REQUIRED": "Confirmez le mot de passe.",
"PASSWORDS_DIFFER": "La confirmation ne correspond pas au mot de passe.",
"CURRENT_PASSWORD_REQUIRED": "Saisissez votre mot de passe actuel.",
"WRONG_CURRENT_PASSWORD": "Mot de passe actuel incorrect.",
"PAST_DATE": "On ne peut pas prendre de rendez-vous à une date passée.",
"PATIENT_HAS_UPCOMING_APPOINTMENT": "Ce patient a déjà un rendez-vous à venir : il faut l'annuler avant d'en prendre un autre.",
"CAPTCHA_BEFORE_ACCOUNT": "Pour créer un compte, recopiez d'abord correctement les caractères de l'image, puis cliquez sur « Créer un compte client »."
},
"CAPTCHA": {
"LABEL": "Recopiez les caractères de l'image",
"IMAGE_ALT": "Image de vérification anti-robots",
"NEW_IMAGE": "Autre image",
"HELP": "Majuscules ou minuscules, peu importe."
},
"INSCRIPTION": {
"TITLE": "Créer un compte client",
"INTRO": "Ce compte vous permettra de prendre vous-même vos rendez-vous.",
"LOGIN_HELP": "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
"PASSWORD_HELP": "Au moins {min} caractères.",
"SUBMIT": "Créer mon compte",
"BACK_TO_LOGIN": "Retour à la connexion",
"DONE": "Votre compte a été créé. Vous pouvez vous connecter avec le login {login}."
},
"COMPTE": {
"CONFIRMATION_LABEL": "Confirmation du mot de passe"
},
"ESPACE": {
"TITLE": "Mon espace",
"HELLO": "Bonjour {nom}",
"MANAGE_ACCOUNT": "Gérer mon compte",
"MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
"VIEW_AGENDA": "Voir un agenda",
"VIEW_AGENDA_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
"MY_APPOINTMENT": "Mon rendez-vous",
"SEE_IN_AGENDA": "voir dans l'agenda",
"CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
"NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
},
"MON_COMPTE": {
"TITLE": "Gérer mon compte",
"NEW_PASSWORD_LABEL": "Nouveau mot de passe",
"CURRENT_PASSWORD_LABEL": "Mot de passe actuel",
"CURRENT_PASSWORD_HELP": "Obligatoire pour enregistrer une modification.",
"BACK": "Retour à mon espace",
"DELETE_TITLE": "Supprimer mon compte",
"DELETE_HELP": "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous.",
"DELETE_CONFIRM": "Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.",
"DELETE_SUBMIT": "Supprimer mon compte",
"UPDATED": "Votre compte a été modifié.",
"DELETED": "Votre compte a été supprimé."
},
"CONFIRMATION": {
"TITLE": "Confirmation",
"NO": "Non, revenir",
"YES": "Oui, confirmer"
}
}
|
- les sections regroupent les textes par page ([LOGIN], [AGENDA], [ESPACE]...) ou par thème ([ERRORS], [CONFIRMATION]). Certains noms ([DOCTOR_DAY_PICKER], [BOOKING_DIALOG]) viennent des premières versions de l'application, dont le client était écrit avec [Angular] ;
- ligne 80 : un texte avec deux paramètres, [{nom}] et [{login}], remplacés au moment de la traduction ;
- lignes 98-137 : les messages d'erreur. Ce sont les clés qu'utilisent les [MetierException] de la couche métier, les annotations de validation des formulaires ([@NotBlank(message = "ERRORS.FIRSTNAME_REQUIRED")]), les contrôleurs et la page d'erreur. Trois d'entre eux ont un paramètre : [{minutes}] (lignes 124 et 126) et [{min}] (ligne 129).
11.18.2. i18n/en.json
| {
"APP": {
"TITLE": "RdvMedecins",
"LOGOUT": "Log out",
"MENU_AGENDA": "Schedule",
"MENU_DOCTORS": "Doctors",
"MENU_PATIENTS": "Patients",
"MENU_MY_SPACE": "My space"
},
"LOGIN": {
"TITLE": "Login",
"LOGIN_LABEL": "Login",
"PASSWORD_LABEL": "Password",
"SUBMIT": "Log in",
"ERROR": "Incorrect login or password.",
"DEMO_ACCOUNTS": "Demo accounts (password = login):",
"DEMO_ADMIN": "ADMIN role: appointments + doctor and patient management",
"DEMO_DOCTOR": "DOCTOR role: booking and cancelling appointments",
"DEMO_USER": "USER role (patients): booking for themselves, managing their account",
"CREATE_ACCOUNT": "Create a patient account",
"CREATE_ACCOUNT_HELP": "To create an account, first type the characters shown in the image."
},
"DOCTOR_DAY_PICKER": {
"DOCTOR_LABEL": "Doctor",
"CHOOSE_DOCTOR": "-- choose a doctor --",
"DAY_LABEL": "Day",
"VIEW_AGENDA": "View schedule"
},
"AGENDA": {
"PAGE_TITLE": "Schedule",
"TITLE_PREFIX": "Schedule for",
"COLUMN_SLOT": "Slot",
"COLUMN_STATUS": "Status",
"FREE": "Free",
"BOOK": "Book",
"CANCEL_APPOINTMENT": "Cancel",
"CANCEL_CONFIRM": "Cancel this appointment?",
"EMPTY_STATE": "Choose a doctor and a day, then click \"View schedule\".",
"NO_SLOTS": "This doctor has no time slots.",
"BOOKED": "Appointment booked.",
"CANCELLED": "Appointment cancelled.",
"BOOKED_BY_OTHER": "Booked",
"ALREADY_BOOKED_INFO": "You already have an upcoming appointment: cancel it to book another one."
},
"BOOKING_DIALOG": {
"TITLE": "Book this slot",
"CLOSE_ARIA": "Close",
"SLOT": "Slot {horaire}",
"PATIENT_LABEL": "Patient",
"CHOOSE_PATIENT": "-- choose a patient --",
"CANCEL": "Cancel",
"CONFIRM": "Confirm"
},
"PERSONNES": {
"COLUMN_TITLE": "Title",
"COLUMN_FIRSTNAME": "First name",
"COLUMN_LASTNAME": "Last name",
"COLUMN_LOGIN": "Login",
"EDIT": "Edit",
"DELETE": "Delete",
"SAVE": "Save",
"BACK": "Back to the list",
"TITLE_LABEL": "Title",
"FIRSTNAME_LABEL": "First name",
"LASTNAME_LABEL": "Last name",
"LOGIN_LABEL": "Login",
"PASSWORD_LABEL": "Password",
"LOGIN_HELP_CREATE": "Optional: defaults to the last name in lower case.",
"LOGIN_HELP_EDIT": "Leave empty to keep it unchanged.",
"PASSWORD_HELP_CREATE": "Optional: defaults to the login.",
"PASSWORD_HELP_EDIT": "Leave empty to keep it unchanged.",
"ACCOUNT_SECTION": "User account"
},
"MEDECINS": {
"TITLE": "Doctors",
"ADD": "Add a doctor",
"EDIT": "Edit a doctor",
"EMPTY": "No doctors.",
"DELETE_CONFIRM": "Delete this doctor, their time slots and their user account?",
"ADDED": "Doctor {nom} has been added (login: {login}).",
"UPDATED": "Doctor {nom} has been updated.",
"DELETED": "Doctor {nom} has been deleted."
},
"CLIENTS": {
"TITLE": "Patients",
"ADD": "Add a patient",
"EDIT": "Edit a patient",
"EMPTY": "No patients.",
"DELETE_CONFIRM": "Delete this patient, their user account and all their appointments?",
"ADDED": "Patient {nom} has been added (login: {login}).",
"UPDATED": "Patient {nom} has been updated.",
"DELETED": "Patient {nom} has been deleted."
},
"ERROR_PAGE": {
"TITLE": "Error",
"BACK": "Back to the home page"
},
"ERRORS": {
"LOGIN_TAKEN": "This login is already used by another account.",
"DOCTOR_ALREADY_EXISTS": "A doctor with this first and last name already exists.",
"PATIENT_ALREADY_EXISTS": "A patient with this first and last name already exists.",
"DOCTOR_HAS_APPOINTMENTS": "This doctor has appointments: cancel them before deleting the doctor.",
"SLOT_ALREADY_BOOKED": "This slot has just been booked by someone else.",
"CONCURRENT_UPDATE": "This record was changed by another user in the meantime. Go back to the list and reopen it before trying again.",
"DOCTOR_NOT_FOUND": "This doctor does not exist (any more).",
"PATIENT_NOT_FOUND": "This patient does not exist (any more).",
"SLOT_NOT_FOUND": "This slot does not exist (any more).",
"APPOINTMENT_NOT_FOUND": "This appointment does not exist (any more).",
"INVALID_DATE": "The requested date is not valid.",
"CHOOSE_PATIENT": "Choose a patient.",
"INVALID_FORM": "The form contains errors.",
"INVALID_TITLE": "Choose a title from the list.",
"FIRSTNAME_REQUIRED": "The first name is required.",
"FIRSTNAME_TOO_LONG": "The first name must not exceed 20 characters.",
"LASTNAME_REQUIRED": "The last name is required.",
"LASTNAME_TOO_LONG": "The last name must not exceed 20 characters.",
"INVALID_LOGIN": "At most 30 characters: unaccented lower-case letters, digits, dot, hyphen, underscore.",
"PASSWORD_TOO_LONG": "The password must not exceed 72 characters.",
"SESSION_EXPIRED": "Your session has expired: please log in again.",
"BAD_REQUEST": "The request is invalid.",
"FORBIDDEN": "Your role does not allow you to access this page.",
"NOT_FOUND": "This page does not exist.",
"SERVER_ERROR": "An unexpected error occurred. Please try again later.",
"TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {minutes} minute(s).",
"WRONG_CAPTCHA": "The characters you typed do not match the image. A new image is shown.",
"TOO_MANY_ACCOUNTS": "Too many accounts have been created from your connection. Try again in {minutes} minute(s).",
"LOGIN_REQUIRED": "The login is required.",
"PASSWORD_REQUIRED": "The password is required.",
"PASSWORD_TOO_SHORT": "The password must be at least {min} characters long.",
"CONFIRMATION_REQUIRED": "Please confirm the password.",
"PASSWORDS_DIFFER": "The confirmation does not match the password.",
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
"WRONG_CURRENT_PASSWORD": "Incorrect current password.",
"PAST_DATE": "Appointments cannot be booked for a past date.",
"PATIENT_HAS_UPCOMING_APPOINTMENT": "This patient already has an upcoming appointment: it must be cancelled before booking another one.",
"CAPTCHA_BEFORE_ACCOUNT": "To create an account, first type the characters shown in the image correctly, then click \"Create a patient account\"."
},
"CAPTCHA": {
"LABEL": "Type the characters shown in the image",
"IMAGE_ALT": "Anti-robot verification image",
"NEW_IMAGE": "Another image",
"HELP": "Upper or lower case, it does not matter."
},
"INSCRIPTION": {
"TITLE": "Create a patient account",
"INTRO": "With this account you can book your own appointments.",
"LOGIN_HELP": "Unaccented lower-case letters, digits, dot, hyphen, underscore.",
"PASSWORD_HELP": "At least {min} characters.",
"SUBMIT": "Create my account",
"BACK_TO_LOGIN": "Back to login",
"DONE": "Your account has been created. You can now log in with the login {login}."
},
"COMPTE": {
"CONFIRMATION_LABEL": "Confirm password"
},
"ESPACE": {
"TITLE": "My space",
"HELLO": "Hello {nom}",
"MANAGE_ACCOUNT": "Manage my account",
"MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
"VIEW_AGENDA": "View a schedule",
"VIEW_AGENDA_HELP": "See a doctor's schedule and book a slot.",
"MY_APPOINTMENT": "My appointment",
"SEE_IN_AGENDA": "see in the schedule",
"CANCEL_APPOINTMENT": "Cancel this appointment",
"NO_APPOINTMENT": "You have no upcoming appointment."
},
"MON_COMPTE": {
"TITLE": "Manage my account",
"NEW_PASSWORD_LABEL": "New password",
"CURRENT_PASSWORD_LABEL": "Current password",
"CURRENT_PASSWORD_HELP": "Required to save any change.",
"BACK": "Back to my space",
"DELETE_TITLE": "Delete my account",
"DELETE_HELP": "Your account will be permanently deleted, together with all your appointments.",
"DELETE_CONFIRM": "Permanently delete your account? Any appointment you have will be cancelled.",
"DELETE_SUBMIT": "Delete my account",
"UPDATED": "Your account has been updated.",
"DELETED": "Your account has been deleted."
},
"CONFIRMATION": {
"TITLE": "Confirmation",
"NO": "No, go back",
"YES": "Yes, confirm"
}
}
|
Le dictionnaire anglais a exactement les mêmes clés, aux mêmes lignes.
11.19. Les fichiers statiques
[Spring Boot] sert tels quels les fichiers du dossier [src/main/resources/static] (cf. exemple 15) : [static/css/styles.css] est servi à l'adresse [/css/styles.css]. Ces adresses sont déclarées publiques dans [SecuriteConfig], pour que la page de connexion ait ses styles. Le dossier [static/lib/bootstrap] contient [Bootstrap] (sa feuille de style [css/bootstrap.min.css] et sa licence), qu'on ne montre pas.
11.19.1. static/css/styles.css
| /* =========================================================================
static/css/styles.css - quelques réglages en plus de Bootstrap
(les mêmes que dans la version ASP.NET Core MVC de cette application).
========================================================================= */
body {
background-color: #f5f7fa;
}
.creneau-libre:hover {
background-color: #e9f7ef;
}
/* -------------------------------------------------------------------------
.zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
(cf. templates/fragments/erreurs.html).
position: sticky + bottom : si la page est plus haute que la fenêtre (un
agenda de 12 créneaux, par exemple), la zone reste collée au bas de la
fenêtre au lieu de disparaître sous la ligne de flottaison - sans jamais
recouvrir le contenu quand la page est courte.
z-index : au-dessus du fond grisé de la fenêtre de réservation (.modal-backdrop).
------------------------------------------------------------------------- */
.zone-erreur {
position: sticky;
bottom: 1rem;
z-index: 1060;
margin-top: 1.5rem;
padding: 0.75rem 1rem;
background-color: #fdecef; /* rose léger */
border: 1px solid #f5c2c7;
border-radius: 0.375rem;
color: #842029;
}
/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
transition: border-color 0.15s, box-shadow 0.15s;
}
.carte-option:hover {
border-color: #0d6efd;
box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}
/* l'image du captcha (SVG de 150 x 50 pixels) */
.captcha-image svg {
display: block;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
|
- lignes 10-12 : un créneau réservable change de couleur au survol ;
- lignes 23-33 : la zone d'erreur, sur fond rose léger. [position: sticky] et [bottom: 1rem] : si la page est plus haute que la fenêtre, la zone reste collée en bas de la fenêtre au lieu de disparaître hors de l'écran ; [z-index] la place au-dessus du fond grisé de la fenêtre de réservation ;
- lignes 36-43 : les deux options de « Mon espace » réagissent au survol ;
- lignes 46-50 : le cadre de l'image du captcha.
11.19.2. static/js/app.js
| // =========================================================================
// static/js/app.js - le SEUL JavaScript de l'application.
// -------------------------------------------------------------------------
// Toute l'application fonctionne sans JavaScript. Ce script ajoute un
// confort : avant d'envoyer un formulaire qui porte l'attribut
// data-confirmation="...", une fenêtre modale demande "Êtes-vous sûr ?"
// (la fenêtre elle-même est dans la page : templates/fragments/confirmation.html).
// Si le JavaScript est désactivé, le formulaire part simplement sans
// confirmation - rien ne casse (principe d'"amélioration progressive").
//
// Pourquoi pas window.confirm() ? Sa boîte ne se met pas en forme, et ses
// boutons restent dans la langue du navigateur, pas dans celle de la page.
// =========================================================================
(() => {
const modale = document.getElementById('modale-confirmation');
if (!modale) {
return;
}
const message = document.getElementById('modale-confirmation-message');
const boutonNon = modale.querySelector('.modal-footer [data-action="non"]');
let formulaireEnAttente = null; // le formulaire à envoyer si l'on confirme
let focusPrecedent = null; // l'élément à qui rendre le focus à la fermeture
function ouvrir(formulaire) {
formulaireEnAttente = formulaire;
focusPrecedent = document.activeElement;
// textContent (et non innerHTML) : le message est affiché comme du TEXTE,
// jamais interprété comme du HTML
message.textContent = formulaire.dataset.confirmation;
modale.classList.remove('d-none');
// le focus va sur "Non" : un appui distrait sur Entrée ne supprime rien
boutonNon.focus();
}
function fermer() {
modale.classList.add('d-none');
formulaireEnAttente = null;
if (focusPrecedent) {
focusPrecedent.focus();
}
}
// 1. on intercepte l'envoi des formulaires à confirmer
document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
formulaire.addEventListener('submit', (evenement) => {
evenement.preventDefault();
ouvrir(formulaire);
});
});
// 2. les boutons de la fenêtre (Oui / Non / croix de fermeture)
modale.addEventListener('click', (evenement) => {
const action = evenement.target.closest('[data-action]')?.dataset.action;
if (action === 'oui') {
const formulaire = formulaireEnAttente;
fermer();
// formulaire.submit() envoie le formulaire SANS redéclencher
// l'événement 'submit' : pas de nouvelle demande de confirmation
formulaire.submit();
} else if (action === 'non') {
fermer();
} else if (evenement.target.classList.contains('modal')) {
fermer(); // clic sur le fond grisé, en dehors de la fenêtre
}
});
// 3. clavier : Échap ferme la fenêtre ; Tab reste DANS la fenêtre
// (sinon le focus partirait sur la page grisée, derrière)
modale.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') {
fermer();
} else if (evenement.key === 'Tab') {
const boutons = [...modale.querySelectorAll('button')];
const premier = boutons[0];
const dernier = boutons[boutons.length - 1];
if (evenement.shiftKey && document.activeElement === premier) {
evenement.preventDefault();
dernier.focus();
} else if (!evenement.shiftKey && document.activeElement === dernier) {
evenement.preventDefault();
premier.focus();
}
}
});
})();
|
Le script de la fenêtre de confirmation de l'exemple 21, complété par la gestion du clavier. Il suit le même principe d'amélioration progressive : sans [JavaScript], les formulaires partent sans confirmation, et l'application fonctionne toujours.
- ligne 15 : tout le script est enfermé dans une fonction, appelée aussitôt ([(() => { ... })()]) : ses variables ne sont pas visibles des autres scripts de la page ;
- lignes 16-19 : une page sans fenêtre de confirmation n'a rien à faire ;
- ligne 24 : l'élément qui avait le focus clavier (celui qui reçoit les frappes) avant l'ouverture : on le lui rend à la fermeture ;
- lignes 26-35 : la fenêtre s'ouvre avec le message propre au formulaire ([formulaire.dataset.confirmation] est la valeur de son attribut [data-confirmation]) ; le focus est placé sur « Non, revenir » : un appui distrait sur Entrée ne supprime rien ;
- lignes 37-43 : la fenêtre est cachée, et le focus rendu ;
- lignes 46-51 : on intercepte l'envoi des formulaires qui portent l'attribut [data-confirmation], comme dans l'exemple 21 ;
- lignes 54-67 : « Oui », « Non », la croix de fermeture, et le clic en dehors de la fenêtre. [closest('[data-action]')] trouve l'élément porteur de l'attribut, même si l'on a cliqué sur un élément qu'il contient. L'opérateur [?.] rend [undefined] au lieu d'échouer quand aucun élément n'est trouvé ;
- lignes 71-86 : la touche Échap ferme la fenêtre ; la touche Tab (et Maj+Tab) fait tourner le focus entre les boutons de la fenêtre, au lieu de le laisser partir sur la page grisée, derrière.