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 [ASP.NET Core MVC], de l'application [NestJS] du cours précédent : même base de données, mêmes règles de gestion, mêmes pages, mêmes dictionnaires de traduction. Les pages HTML sont fabriquées par le serveur, avec des vues [Razor].
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 :

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, mais pas le nom des autres patients. Ayant déjà un rendez-vous à venir, il ne peut pas en prendre un autre :

Un patient sans rendez-vous peut réserver un créneau libre, pour lui-même :

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

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
Les étapes de l'installation ont été données au début du document (section « Exécuter l'étude de cas » de l'introduction) : créer la base avec le script SQL, vérifier la chaîne de connexion de [appsettings.json], puis lancer [dotnet run] dans le dossier de l'application. Le dossier de l'application :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | |
Chaque dossier contient un fichier [README.md] qui présente ses fichiers. La console, au démarrage :
En mode développement (cf. [Properties/launchSettings.json]), la console affiche en outre chaque ordre SQL envoyé à la base par EF Core. On peut aussi :
- lancer l'application par [dotnet watch] : elle est recompilée et relancée à chaque modification d'un fichier ;
- l'exécuter pas à pas dans [Visual Studio Code] : avec l'extension [C# Dev Kit], la touche F5 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.
11.4. L'architecture de l'application

- [Program.cs] configure l'application : services (injection de dépendances), middlewares (fichiers statiques, pages d'erreur, langue, authentification), contrôleurs ;
- [Auth] : la connexion, les jetons JWT, les rôles, le captcha, la limitation des tentatives ;
- [Controllers] : les contrôleurs de la couche web. Ils passent tous par un service 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 ;
- [Web] : ce que la couche web a en commun : traduction, messages flash, validation, classe mère des vues, filtre d'exceptions ; [Models] : les modèles des formulaires et des vues ;
- [Metier] : les règles de gestion ;
- [Repositories] : la couche DAO, une classe par table ; [Data] : le [DbContext] d'EF Core ;
- [Entities] : les entités EF Core ; [Domain] : les objets de l'agenda, calculés par la couche métier ;
- [Views] : les vues [Razor] ; [i18n] : les dictionnaires ; [wwwroot] : la feuille de style, le script de la page et [Bootstrap].
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], que l'on exécute comme celui de l'exemple 28 (outil [HeidiSQL] de [Laragon], ou commande [mysql -u root < database/dbrdvmedecins2.sql]). C'est, au mot près, le script de l'application [NestJS] : les deux applications peuvent travailler sur la même base, et l'une peut remplacer l'autre sans rien y changer.
11.5.1. database/dbrdvmedecins2.sql
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
Commentons ce script :
- ligne 30 : 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 ;
- lignes 34-38 : 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 43-52 : 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 46-49 : 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, d'où la taille de la colonne ;
- ligne 51 : 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. [Metier/MetierService.cs]) ;
- lignes 57-69 : les médecins : titre, nom, prénom ;
- ligne 59 : 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. Comme à l'exemple 28, c'est l'application qui l'augmente (cf. le [DbContext], plus loin) ;
- lignes 63-64 : 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 66 : un compte n'appartient qu'à un médecin : la relation entre [medecins] et [users] est une relation « un à un » ;
- ligne 68 : 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 ;
- lignes 74-86 : 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 91-100 : 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 105-115 : 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 114 : 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 ;
- lignes 127-137 : 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], avec un coût de 10 (cf. exemple 25) ; la bibliothèque [BCrypt.Net-Next] de l'application sait les vérifier ;
- lignes 141-144 : 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 146-150 : les clients, de la même façon ;
- lignes 153-157 : 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 160-164 : 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 167-168 : 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. RdvMedecins.csproj
Commentons ce fichier :
- ligne 12 : le SDK des applications web, comme pour tous les exemples (cf. introduction) ;
- lignes 14-23 : les réglages que les exemples trouvaient dans le fichier commun [exemples/Directory.Build.props] : [.NET] 10, types références « nullables », [using] implicites. L'étude de cas est un projet autonome : elle les écrit elle-même. [RootNamespace] est l'espace de noms racine du projet, celui que [Visual Studio Code] propose pour un nouveau fichier ;
- ligne 29 : le fournisseur [MySQL] d'EF Core, qui apporte avec lui EF Core et le pilote [MySqlConnector] (cf. exemple 28) ;
- lignes 31-34 : les deux paquets de l'authentification, déjà utilisés au chapitre 8 : la vérification des jetons JWT, et le hachage des mots de passe ;
- ligne 41 : les dictionnaires de traduction [i18n/*.json] sont lus sur le disque au démarrage. Quand on prépare l'application pour un serveur de production (commande [dotnet publish]), ils doivent être recopiés avec elle : c'est ce que demande [CopyToPublishDirectory] ;
- lignes 43-44 : le script SQL, lui, n'est pas un fichier de l'application : on l'exclut des fichiers publiés, comme à l'exemple 28.
11.6.2. appsettings.json
Le fichier [appsettings.json] tient le rôle du fichier [.env] de l'application [NestJS]. Une différence importante : le fichier [.env] n'était jamais partagé, et un fichier [.env.example] en donnait le modèle. Ici, [appsettings.json] fait partie du projet ; les valeurs propres à une machine (un mot de passe, par exemple) peuvent être fournies autrement, sans le modifier (cf. plus bas).
Commentons ce fichier :
- lignes 1-9 : contrairement au JSON standard, [ASP.NET Core] accepte les commentaires dans ses fichiers de configuration ;
- ligne 13 : l'adresse et le port d'écoute. Les exemples écoutaient sur le port 5000, le port par défaut ; l'application écoute sur le port 8081, comme la version [NestJS] ;
- lignes 16-18 : la chaîne de connexion à la base [dbrdvmedecins2], avec les valeurs par défaut de [Laragon] (utilisateur [root], sans mot de passe), cf. exemple 28 ;
- lignes 19-23 : la version du serveur, qui indique au fournisseur [Pomelo] quel dialecte SQL produire (cf. exemple 28) ;
- lignes 26-36 : la section de l'authentification : la clé secrète qui signe les jetons (au moins 32 caractères, à remplacer impérativement en production), 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 39-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 ;
- lignes 51-57 : les niveaux de journalisation (cf. introduction). Les messages d'EF Core ne sont affichés qu'à partir du niveau [Warning] : les ordres SQL, de niveau [Information], n'apparaissent pas (cf. exemple 28) ;
- ligne 58 : tous les noms de domaine sont acceptés.
Les sections [Auth] et [Protection] sont lues sous forme de classes typées (cf. [Config/AuthOptions.cs], plus bas). Chaque valeur est désignée par son chemin, les niveaux étant séparés par [:] : [Auth:JwtSecret], [ConnectionStrings:RdvMedecins], [MySql:ServerVersion]. [ASP.NET Core] lit la configuration dans plusieurs sources successives, chacune pouvant remplacer les valeurs des précédentes : [appsettings.json], puis [appsettings.{environnement}.json] (cf. fichier suivant), puis, en développement, les « secrets utilisateur » (des valeurs rangées hors du projet, dans le profil de l'utilisateur), puis les variables d'environnement, et enfin les arguments de la ligne de commande. Un mot de passe peut ainsi être donné par une variable d'environnement, où le [:] s'écrit [__] :
Le fichier [README.md] de l'application détaille ces possibilités.
11.6.3. appsettings.Development.json
- ligne 10 : [ASP.NET Core] connaît l'environnement dans lequel s'exécute l'application : [Development], [Production]... Il le lit dans la variable d'environnement [ASPNETCORE_ENVIRONMENT] (cf. fichier suivant) ; à défaut, c'est [Production]. Après [appsettings.json], il lit le fichier [appsettings.{environnement}.json], s'il existe, 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 le niveau [Information] pour la catégorie [Microsoft.EntityFrameworkCore.Database.Command] : la règle la plus précise l'emporte sur celle, plus générale, de [appsettings.json] ([Warning] pour tout [Microsoft.EntityFrameworkCore]), et chaque ordre SQL envoyé à la base est affiché dans la console. C'est l'équivalent de l'option [logging] de [TypeORM], que la version [NestJS] activait hors production. Toutes les autres valeurs (connexion, authentification...) restent celles de [appsettings.json].
11.6.4. Properties/launchSettings.json
Ce fichier n'est utilisé qu'en développement, par [dotnet run], [dotnet watch] et [Visual Studio Code] ; il n'est pas publié. Commentons-le :
- lignes 3-4 : un profil de lancement décrit une façon de lancer l'application. Il n'y en a qu'un ici ; [dotnet run] prend le premier ;
- ligne 5 : l'application est lancée directement, avec son propre serveur web ;
- lignes 6-7 : [dotnet run] affiche ses messages de compilation, et le navigateur s'ouvre sur l'application quand elle est prête ;
- ligne 8 : l'adresse d'écoute, la même que la clé [Urls] de [appsettings.json]. Lancée par ce profil, l'application utilise celle-ci ; lancée hors profil (par exemple une fois publiée), celle de [appsettings.json] ;
- lignes 9-10 : les variables d'environnement données à l'application. [ASPNETCORE_ENVIRONMENT=Development] fixe l'environnement : [appsettings.Development.json] est lu, et les ordres SQL s'affichent. Sur un serveur de production, sans ce fichier ni cette variable, l'environnement est [Production].
11.6.5. Config/AuthOptions.cs
Commentons ce code :
- ligne 15 : la section [Auth] de [appsettings.json], sous la forme d'une classe ordinaire : une propriété par clé, de même nom. C'est le modèle des options ([options pattern]) d'[ASP.NET Core]. Dans [Program.cs], [Configure<AuthOptions>(...)] lie la section à la classe : le framework crée un objet [AuthOptions] et remplit ses propriétés avec les valeurs de la section, en les convertissant au bon type (["7200"] devient l'entier 7200, [false] le booléen [false]). Une classe qui en a besoin le reçoit par injection de dépendances, sous la forme d'un [IOptions<AuthOptions>], dont la propriété [Value] est l'objet rempli : c'est ce que fait [Auth/AuthService.cs], avec son paramètre [IOptions<AuthOptions> options] et son champ [auth = options.Value]. On évite ainsi de lire des chaînes de caractères un peu partout, comme le faisait [process.env.JWT_SECRET] dans la version [NestJS] : les noms des clés sont vérifiés par le compilateur, et les valeurs sont déjà converties ;
- ligne 17 : le nom de la section, écrit une seule fois ;
- lignes 21-29 : les trois valeurs de la section. Les valeurs initiales ([7200], [false] pour un booléen) sont celles qu'on obtient si la clé est absente de la configuration.
11.6.6. Config/ProtectionOptions.cs
- lignes 10-25 : la section [Protection] de [appsettings.json], sur le même modèle. Elle est injectée, sous la forme d'un [IOptions<ProtectionOptions>], dans [Auth/LimiteurTentativesService.cs], qui y lit ses seuils.
11.7. Le démarrage de l'application
11.7.1. Program.cs
Le fichier [Program.cs] tient à la fois le rôle de [main.ts] (le démarrage, les middlewares) et celui de [app.module.ts] (quel service fournit quoi) de la version [NestJS]. Il suit le plan de tous les exemples : d'abord enregistrer les services, ensuite construire le pipeline des middlewares. Nous le découpons en extraits.
11.7.1.1. La configuration
- lignes 21-31 : les espaces de noms utilisés, en plus des [using] implicites : ceux du framework (localisation, EF Core, encodage HTML), et ceux de l'application, un par dossier ;
- ligne 33 : le constructeur de l'application, comme dans tous les exemples. Il a déjà lu la configuration ([appsettings.json], [appsettings.Development.json], variables d'environnement...), accessible par [builder.Configuration] ;
- lignes 40-41 : le modèle des options (cf. [Config/AuthOptions.cs]) : chaque section est liée à sa classe. [GetSection("Auth")] désigne la section ; [Configure<T>] enregistre le service [IOptions<T>] correspondant, que les services de l'authentification recevront par injection ;
- ligne 42 : la même section, lue cette fois immédiatement sous forme d'objet ([Get<AuthOptions>()]), ou un objet aux valeurs par défaut si la section est absente. La configuration de [JwtBearer] (plus bas) a besoin de la clé secrète dès maintenant, pendant l'enregistrement des services : à ce moment, le conteneur d'injection de dépendances n'est pas encore construit, et aucun [IOptions<AuthOptions>] ne peut être injecté.
11.7.1.2. La base de données et les couches DAO et métier
- lignes 4-5 : la chaîne de connexion, comme à l'exemple 28. L'opérateur [??] rend la valeur de gauche si elle n'est pas [null], sinon celle de droite : si la clé est absente, l'application s'arrête aussitôt, avec un message qui dit ce qui manque, plutôt qu'à la première requête avec une erreur obscure ;
- ligne 6 : la version du serveur [MySQL], avec une valeur par défaut si la clé est absente ;
- ligne 7 : le [DbContext] de l'application (cf. [Data/RdvMedecinsDbContext.cs]), un par requête ([Scoped]), avec le fournisseur [MySQL] (cf. exemple 28) ;
- lignes 11-15 : la couche DAO : une classe par table (sauf [users], dont le repository sert à l'authentification). Elle est [Scoped], comme le [DbContext] qu'elle utilise. Un service ne doit jamais vivre plus longtemps que ses dépendances : un DAO [Singleton] garderait pour toujours le [DbContext] de la première requête, alors que celui-ci est détruit à la fin de cette requête (cf. chapitre 6) ;
- ligne 19 : la couche métier. [AddScoped<IMetier, MetierService>()] se lit : « quand un service demande un [IMetier], lui fournir un [MetierService] ». Ceux qui utilisent la couche métier dépendent de l'interface, pas de la classe : on pourrait remplacer l'implémentation (par une version de test, par exemple) en ne modifiant que cette ligne. La version [NestJS] avait besoin pour cela d'un jeton ([METIER_TOKEN]), les interfaces [TypeScript] disparaissant à la compilation ; en [C#], l'interface elle-même sert de jeton ;
- lignes 22-23 : le cache de la couche web (les listes des médecins et des clients) est de portée application : [Singleton], une seule instance partagée par toutes les requêtes (cf. exemple 23). Sa façade, [ApplicationModel], par laquelle passent tous les contrôleurs, utilise la couche métier : elle est donc [Scoped]. Le cache lui-même, qui ne dépend de rien, peut vivre plus longtemps qu'elle.
11.7.1.3. L'authentification, la traduction, l'encodage HTML
- ligne 2 : le service de connexion (vérification du login et du mot de passe, fabrication du jeton, cf. exemple 25). Il utilise le repository des comptes : il est [Scoped] ;
- lignes 3-5 : les captchas en attente, les compteurs d'échecs et les laissez-passer de création de compte (cf. exemple 27). Ces données sont gardées en mémoire, et doivent être les mêmes pour toutes les requêtes : les trois services sont des [Singleton]. La contrepartie : elles sont perdues à chaque redémarrage du serveur ;
- ligne 6 : la configuration de [JwtBearer] (jeton lu dans le cookie, redirection vers la page de connexion...), vue à l'exemple 25. Elle est rangée dans une méthode d'extension de [IServiceCollection] (cf. [Auth/AuthentificationJwt.cs]), qui s'appelle comme une méthode du framework et allège d'autant [Program.cs] ;
- ligne 7 : les services de l'autorisation, utilisés par [[Authorize]] et par le middleware [UseAuthorization] ;
- ligne 10 : le service de traduction, qui lit les dictionnaires une fois pour toutes (cf. exemple 22) ;
- ligne 16 : l'encodeur HTML de [Razor] laisse passer les caractères de toutes les langues, comme depuis l'exemple 15.
11.7.1.4. MVC et ses options
- ligne 2 : les contrôleurs et les vues [Razor]. La fonction passée en paramètre règle les options de MVC ;
- ligne 5 : un filtre d'exceptions global (cf. exemple 24), qui s'applique à toutes les actions. Il ne traite qu'un cas : une [SessionInvalideException] (le jeton est valide, mais le compte qu'il désigne a été supprimé entre-temps) conduit à la page de connexion. Les autres exceptions continuent leur chemin, jusqu'au middleware [UseExceptionHandler] (plus bas) ;
- ligne 10 : un piège des types « nullables ». Quand ils sont activés, MVC considère une propriété [string] (non nullable) d'un modèle comme obligatoire, exactement comme si elle portait l'attribut [[Required]], avec un message d'erreur en anglais que l'on ne maîtrise pas. Cette option supprime ce comportement implicite : un champ n'est obligatoire que s'il porte explicitement [[Required]], avec notre propre message (cf. [Models/Formulaires.cs]) ;
- lignes 14-25 : les messages des erreurs de liaison (cf. exemple 19) : un texte là où un nombre est attendu ([version=abc]), une valeur absente... L'exemple 19 n'en redéfinissait que deux, en français ; l'étude de cas les redéfinit tous, et chacun rend la même clé de traduction, [ERRORS.INVALID_FORM], et non un texte : le message est traduit au moment de l'affichage, dans la langue de l'utilisateur. Le caractère [_] désigne un paramètre qu'on n'utilise pas (le nom du champ, la valeur saisie) : le message est le même quel que soit le champ. Ces erreurs ne devraient jamais arriver avec les formulaires de l'application : elles signalent une requête fabriquée à la main.
11.7.1.5. Le chargement du cache
- ligne 1 : la construction de l'application. Le conteneur d'injection de dépendances est désormais créé : on ne peut plus enregistrer de service, mais on peut en obtenir ;
- lignes 9-12 : les listes des médecins et des clients sont chargées dans le cache avant que le serveur ne commence à écouter. [ApplicationModel] est un service [Scoped] : il ne peut être créé qu'à l'intérieur d'une portée, et, en dehors d'une requête, il n'y en a pas. [CreateScope()] en crée une ; [GetRequiredService<ApplicationModel>()] y obtient le service (avec la couche métier, les repositories et le [DbContext] dont il dépend), et [ChargerAsync()] remplit le cache, qui est un [Singleton] et survit donc à la portée. L'instruction [using] détruit la portée, et le [DbContext] avec elle, à la sortie du bloc. Si la base est inaccessible, l'exception d'EF Core n'est pas interceptée : l'application s'arrête, avec le message d'erreur dans la console. C'est le premier signe d'une chaîne de connexion erronée ou d'un serveur [MySQL] arrêté.
11.7.1.6. Le pipeline des middlewares
Chaque requête traverse les middlewares dans l'ordre où ils sont déclarés (cf. chapitre 7) : cet ordre est donc important.
- ligne 8 : le premier middleware enveloppe tous les autres. Si une exception n'a été traitée par personne (une erreur de programmation, une base devenue inaccessible...), il l'inscrit dans le journal et ré-exécute la requête avec l'adresse [/erreur/500] : l'utilisateur voit une page d'erreur de l'application, et non un message technique (cf. chapitre 7) ;
- ligne 9 : une réponse d'erreur sans contenu (400, 403, 404) est transformée de la même façon en page, par l'action [/erreur/{code}] du contrôleur [ErreurController] (cf. exemples 24 et 26). La requête ré-exécutée traverse à nouveau les middlewares suivants : la page d'erreur est donc traduite, et connaît l'utilisateur connecté ;
- ligne 12 : les fichiers statiques du dossier [wwwroot] (cf. exemple 15) : la feuille de style, le script de la page et [Bootstrap], rangé ici dans [wwwroot/lib/bootstrap]. Placé tôt dans le pipeline, ce middleware répond aux demandes de fichiers sans les faire passer par la localisation ni l'authentification ;
- lignes 17-23 : la configuration de la localisation, comme à l'exemple 22. Les langues acceptées et la langue par défaut sont celles du service de traduction ([Traducteur.Langues], [Traducteur.LangueParDefaut]) : elles ne sont écrites qu'à un seul endroit ;
- ligne 25 : seul le cookie déposé par [LangueController] décide de la langue (cf. exemple 22). La syntaxe [[...]] est une expression de collection : ici une liste d'un seul élément ;
- ligne 26 : le middleware de localisation, qui fixe la langue de chaque requête ;
- lignes 30-32 : le routage, puis l'authentification, puis l'autorisation. Le routage choisit l'action qui traitera la requête, d'après les routes déclarées par attributs. Les exemples ne l'appelaient pas : [WebApplication] l'ajoute alors de lui-même en tête du pipeline. L'appeler explicitement rend l'ordre visible : l'autorisation doit venir après le routage, car c'est l'action choisie qui porte l'attribut [[Authorize]] à vérifier ; et après l'authentification, qui établit, à partir du jeton, qui est l'utilisateur (cf. exemple 25) ;
- ligne 35 : les contrôleurs et leurs routes, déclarées par attributs ([[HttpGet("agenda")]]...) ;
- ligne 37 : le serveur démarre et attend les requêtes, sur le port 8081.
11.8. Les entités
Les entités EF Core font correspondre les tables de la base et des classes [C#] (cf. exemple 28). Elles reprennent une à une les entités [TypeORM] de la version [NestJS] : les attributs [[Table]], [[Column]], [[Key]], [[ForeignKey]] y jouent le rôle des décorateurs [@Entity], [@Column], [@PrimaryGeneratedColumn], [@JoinColumn]. La base n'est pas créée par EF Core, mais par le script SQL [database/dbrdvmedecins2.sql] : les entités doivent simplement décrire fidèlement les tables qui existent déjà.
11.8.1. Entities/AbstractEntity.cs
- ligne 23 : une classe abstraite, sans attribut [[Table]] : elle ne correspond à aucune table, mais ses propriétés sont ajoutées à celles des tables de ses classes filles. Toutes les entités de l'application en héritent, et ont donc un identifiant et une version ;
- lignes 28-30 : la clé primaire ([[Key]]), colonne [id]. Une clé entière est, par convention d'EF Core, générée par la base ([AUTO_INCREMENT]) : EF Core relit sa valeur après chaque [INSERT] ;
- lignes 40-42 : la version de la ligne, pour le verrou optimiste (cf. exemple 28). [[ConcurrencyCheck]] ajoute à chaque [UPDATE] la condition [AND version = <valeur lue>]. Contrairement à [@VersionColumn] de [TypeORM], EF Core n'augmente pas lui-même la version : c'est le [DbContext] qui s'en charge (cf. plus bas).
11.8.2. Entities/Personne.cs
- ligne 17 : la classe mère, abstraite elle aussi, 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 19-29 : le titre (Mme, Mr...), le nom et le prénom. [[MaxLength]] donne la taille de la colonne. Ces propriétés sont de type [string] (et non [string?]) : elles ne doivent jamais valoir [null], et le compilateur exige donc une valeur initiale, ici la chaîne vide [""] ;
- lignes 42-43 : la clé étrangère vers le compte utilisateur, colonne [user]. On peut lire l'identifiant du compte sans charger le compte lui-même : c'est ce que faisait [@RelationId] dans la version [NestJS] ;
- lignes 47-48 : la propriété de navigation : l'objet [User] lui-même. [[ForeignKey(nameof(IdUser))]] indique quelle propriété contient la clé étrangère ([nameof(IdUser)] vaut la chaîne ["IdUser"], mais le compilateur vérifie que la propriété existe). Une propriété de navigation n'est remplie que si la requête le demande ([Include], cf. la couche DAO) ; sinon elle vaut [null]. L'écriture [= null!] fait taire l'avertissement du compilateur : on promet de ne lire la propriété que lorsqu'elle a été chargée. Le caractère « un pour un » de la relation est précisé dans le [DbContext]. Une relation déclarée dans une classe mère est héritée, comme les colonnes : les tables [medecins] et [clients] ont chacune leur relation vers [users].
11.8.3. Entities/Medecin.cs
- ligne 15 : la classe [Medecin] correspond à la table [medecins]. Elle hérite de [Personne] toutes ses propriétés. 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 (cf. exemple 28) ;
- ligne 21 : la relation inverse de celle de [Creneau] vers son médecin : les créneaux de ce médecin. EF Core associe de lui-même les deux navigations ([Medecin.Creneaux] et [Creneau.Medecin]), qui décrivent la même relation « un médecin, plusieurs créneaux » vue de chaque côté. Comme toute navigation, elle n'est remplie que sur demande ; l'application ne s'en sert d'ailleurs pas : elle lit les créneaux par la couche DAO. [[]] est une expression de collection : ici, une liste vide.
11.8.4. Entities/Client.cs
Un client, c'est-à-dire un patient du cabinet (et non un client HTTP) : une [Personne] de la table [clients] (ligne 15), sans propriété supplémentaire.
11.8.5. Entities/Creneau.cs
- lignes 14-17 : l'heure et la minute de début et de fin du créneau. L'attribut et la propriété sont écrits sur la même ligne : c'est permis, et plus compact pour ces quatre colonnes semblables ;
- lignes 21-25 : la relation « plusieurs à un » vers le médecin : plusieurs créneaux pour un médecin. Même technique que dans [Personne] : la clé étrangère [IdMedecin] (colonne [id_medecin]) et la navigation [Medecin], reliées par [[ForeignKey]]. EF Core n'a besoin d'aucune autre précision : une référence d'un côté, une liste de l'autre, c'est une relation « un à plusieurs ».
11.8.6. Entities/Rv.cs
- ligne 15 : 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 22-23 : le jour du rendez-vous. Le type [DateOnly] (une date sans heure) correspond exactement au type SQL [DATE] : pas d'heure, donc pas de problème de fuseau horaire. La version [NestJS] manipulait une chaîne [aaaa-mm-jj], faute d'un tel type en [JavaScript]. Deux [DateOnly] se comparent directement avec [<], [>=]..., en [C#] comme dans les requêtes [LINQ] ;
- lignes 25-35 : le client et le créneau du rendez-vous : deux relations « plusieurs à un », écrites chacune avec une clé étrangère et une navigation. Pour créer un rendez-vous, il suffit de renseigner les clés étrangères [IdClient] et [IdCreneau] : c'est ce que fait la couche métier.
11.8.7. Entities/Role.cs
- lignes 17-21 : 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] ou [Role.USER], et le compilateur refuse toute autre valeur. C'est l'équivalent du type union de chaînes ['ADMIN' | 'DOCTOR' | 'USER'] de la version [NestJS]. Par défaut, EF Core rangerait une énumération en base sous forme de nombre (0, 1, 2) ; la colonne [role] contient du texte : la conversion est demandée dans le [DbContext].
11.8.8. Entities/User.cs
- ligne 18 : 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 21-23 : le login ;
- lignes 26-28 : le hachage [bcrypt] du mot de passe, jamais le mot de passe lui-même (cf. exemple 25) ;
- lignes 32-34 : le nom affiché dans la barre de navigation une fois connecté (« Marie PELISSIER ») ;
- lignes 37-38 : le rôle, de type [Role]. Comme la classe [User] ne déclare pas de navigation vers le médecin ou le client qui la possède, la relation n'est connue que du côté de [Personne].
11.9. Le DbContext
Le [DbContext] de l'application reprend celui de l'exemple 28, avec cinq tables au lieu d'une, et quelques précisions sur le modèle que les attributs ne savent pas exprimer.
11.9.1. Data/RdvMedecinsDbContext.cs
Commentons ce code :
- ligne 29 : comme à l'exemple 28, le [DbContext] reçoit ses options (fournisseur [MySQL], chaîne de connexion) de [AddDbContext], dans [Program.cs]. Il est enregistré avec la durée de vie [Scoped] : un [DbContext] par requête, partagé par tous les repositories de cette requête, donc une seule connexion et, le cas échéant, une seule transaction. Il tient ainsi à la fois le rôle de la [DataSource] et des [Repository<T>] de [TypeORM] ;
- lignes 33-37 : un [DbSet] par table. Il n'y en a ni pour [AbstractEntity] ni pour [Personne] : ces classes abstraites n'ont pas de table. EF Core ne les intègre pas au modèle comme entités ; il se contente d'ajouter leurs propriétés à celles des classes filles ;
- ligne 39 : [OnModelCreating] est appelée une fois, quand EF Core construit son modèle (la description des tables et des relations). Elle complète les attributs des entités, par une API fluide : des appels de méthodes enchaînés ;
- lignes 47-48 : la relation entre un médecin (un client) et son compte est « un pour un » : [HasOne(m => m.User)] (un médecin a un compte), [WithOne()] (un compte a au plus un médecin ; les parenthèses vides indiquent que [User] n'a pas de navigation en retour), [HasForeignKey<Medecin>(m => m.IdUser)] (la clé étrangère est dans la table [medecins]). Sans ces lignes, EF Core supposerait une relation « plusieurs à un » (plusieurs médecins pour un même compte), ce qui fonctionnerait aussi, mais décrirait mal le modèle : dans la base, la colonne [user] est unique ;
- ligne 52 : [HasConversion<string>()] range le rôle en base sous forme de texte (['ADMIN']...), et le reconvertit en [Role] à la lecture ;
- lignes 68-77 : l'augmentation de la version de chaque entité modifiée, juste avant l'enregistrement, comme à l'exemple 28. [ChangeTracker.Entries<AbstractEntity>()] parcourt toutes les entités suivies dont la classe dérive de [AbstractEntity] : grâce à la classe mère commune, cette seule boucle vaut pour les cinq tables. On redéfinit la variante de [SaveChangesAsync] à deux paramètres, parce que c'est elle qu'appelle finalement [SaveChangesAsync()] sans paramètre. EF Core compare l'ancienne version dans la clause [WHERE] de l'[UPDATE], et écrit la nouvelle : si la ligne a changé entre-temps, aucune ligne n'est modifiée, et EF Core lève une [DbUpdateConcurrencyException].
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.GetAgendaMedecinJourAsync]) et affiché par le contrôleur de l'agenda.
11.10.1. Domain/CreneauMedecinJour.cs
- ligne 17 : un créneau, et le rendez-vous pris sur ce créneau ce jour-là, [null] si le créneau est libre (d'où le type [Rv?]). Un [record] écrit ainsi, avec ses paramètres entre parenthèses (record positionnel), déclare en une ligne une classe, son constructeur et ses propriétés [Creneau] et [Rv], qu'on ne peut plus modifier après la construction : c'est l'équivalent de la syntaxe [constructor(public creneau: Creneau, ...)] de la version [NestJS]. Nous avons déjà rencontré les records pour les données transmises à la couche métier (cf. exemple 28).
11.10.2. Domain/AgendaMedecinJour.cs
L'agenda (ligne 14) : un médecin, un jour, et la liste de ses créneaux, libres ou occupés, dans l'ordre chronologique.
11.11. La couche DAO
Une classe par table, comme à l'exemple 28 : des méthodes courtes, sans règle de gestion, qui ne font qu'interroger ou modifier la base. On les appelle ici des repositories (« dépôts »), comme dans la version [NestJS]. Deux différences avec celle-ci :
- les requêtes sont écrites en [LINQ], qu'EF Core traduit en SQL : on navigue d'une entité à l'autre par les propriétés de navigation, et EF Core écrit lui-même les jointures ;
- les méthodes n'ont pas de paramètre [manager] pour les transactions : tous les repositories d'une requête partagent le même [DbContext], et une transaction ouverte sur ce [DbContext] par la couche métier englobe automatiquement toutes leurs requêtes.
Toutes les méthodes sont asynchrones et rendent une [Task] : l'appelant l'attend avec [await]. Beaucoup sont écrites sous la forme [=> expression] : elles rendent directement la [Task] de la requête, sans [async] ni [await], puisqu'elles n'ont rien à faire après.
11.11.1. Repositories/PersonneRepository.cs
Dans la version [NestJS], les repositories des médecins et des clients étaient identiques, à une ligne près. En [C#], une classe générique les écrit une seule fois.
Commentons ce code :
- ligne 24 : [PersonneRepository<T>] est une classe générique : [T] est un paramètre de type, remplacé par un type réel ([Medecin] ou [Client]) quand on utilise la classe, comme [T] dans [List<T>]. La contrainte [where T : Personne] n'accepte pour [T] que [Personne] ou ses classes filles : le compilateur sait alors qu'un [T] possède [Id], [Nom], [Prenom], [IdUser], [User]... et accepte [p.Nom] dans les requêtes. La classe est abstraite : on n'utilise que ses deux classes filles, plus bas ;
- ligne 26 : le paramètre [db] du constructeur principal n'est visible que dans cette classe. On le recopie dans un champ [protected], pour que les classes filles ([ClientRepository]) y aient accès ;
- ligne 29 : la table des personnes de type [T]. [db.Set<T>()] rend le [DbSet] de ce type : [db.Medecins] pour [T = Medecin], [db.Clients] pour [T = Client] ;
- lignes 38-39 : toutes les personnes, triées par nom puis prénom ([OrderBy], puis [ThenBy] pour départager les noms égaux). [Include(p => p.User)] charge aussi le compte de chaque personne, par une jointure ([INNER JOIN users]) : sans lui, [p.User] vaudrait [null]. Le compte sert à afficher le login dans les listes. [AsNoTracking()] (cf. exemple 28) : ces listes sont gardées en mémoire par [ApplicationModel] bien après la fin de la requête ; il est inutile que le [DbContext] les suive. Les expressions comme [p => p.Nom] ne sont pas exécutées en [C#] : EF Core les analyse pour les traduire en SQL ([ORDER BY nom, prenom]) ;
- lignes 41-42 : une personne et son compte, ou [null]. Ici, pas de [AsNoTracking()] : la couche métier modifie la personne lue (et son compte), puis appelle [SaveAsync] ; le [DbContext], qui suit les deux objets, sait ce qui a changé ;
- lignes 45-46 : la personne dont le compte est [idUser]. C'est ainsi qu'un patient connecté, dont le jeton contient l'identifiant du compte, retrouve sa fiche de client. Le filtre porte sur la clé étrangère elle-même ([WHERE clients.user = ...]) ; la jointure avec [users] ne sert qu'à charger le compte ([Include]) ;
- lignes 52-53 : un homonyme éventuel. L'opérateur [==] est traduit en [=] SQL : 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é (cf. exemple 28). Une comparaison faite en [C#] tiendrait compte des majuscules ;
- lignes 57-64 : la création ou la modification, comme à l'exemple 28 : une personne nouvelle ([Id] à 0) est ajoutée au [DbSet], une personne déjà suivie n'a besoin de rien. Attention : [SaveChangesAsync] enregistre toutes les modifications en attente du [DbContext], pas seulement celles de [personne] : le compte modifié en même temps part dans le même appel ;
- ligne 69 : la suppression par [ExecuteDeleteAsync] (cf. exemple 28) : un [DELETE] envoyé directement à la base, sans lecture préalable. Il ne passe pas par le suivi des modifications, ni donc par le verrou optimiste ;
- ligne 73 : le repository des médecins : [PersonneRepository<Medecin>], sans rien de plus. Il transmet le [DbContext] reçu au constructeur de sa classe mère ; le point-virgule final remplace un corps de classe vide ;
- ligne 75 : le repository des clients, avec une méthode supplémentaire ;
- lignes 89-92 : 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.AjouterRvAsync]) 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 ». [LINQ] ne sait pas exprimer [FOR UPDATE] : on écrit la requête SQL soi-même avec [FromSqlInterpolated]. La chaîne [$"..."] est une chaîne interpolée ; [FromSqlInterpolated] ne la recopie pas telle quelle : chaque valeur entre accolades ([{id}]) devient un paramètre SQL. Il n'y a donc pas de concaténation de chaînes, et pas d'injection SQL possible. La requête est exécutée telle quelle ([ToListAsync]), et le premier client pris dans la liste obtenue ([FirstOrDefault], en mémoire) : un opérateur [LINQ] ajouté à la suite de [FromSqlInterpolated] amènerait EF Core à envelopper notre SQL dans une sous-requête.
11.11.2. Repositories/CreneauRepository.cs
- lignes 15-16 : un créneau, avec son médecin ([Include]) ;
- lignes 19-24 : les créneaux d'un médecin, dans l'ordre chronologique : [Where] filtre, [OrderBy] et [ThenBy] trient (par heure, puis par minute de début). La requête se construit méthode par méthode, comme avec le QueryBuilder de [TypeORM], et n'est exécutée qu'à l'appel de [ToListAsync] : [SELECT ... FROM creneaux WHERE id_medecin = @p ORDER BY hdebut, mdebut]. La valeur [idMedecin] est, elle aussi, transmise comme paramètre SQL ;
- lignes 28-29 : 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.
11.11.3. Repositories/RvRepository.cs
Commentons ce code :
- lignes 19-20 : un rendez-vous avec son client et son créneau : deux [Include], donc deux jointures ;
- lignes 22-29 : même schéma que pour les personnes. En pratique, il ne sert qu'à la création : la couche métier fabrique un [Rv] dont seules les clés étrangères sont renseignées ; après [SaveChangesAsync], [rv.Id] contient l'identifiant attribué par la base ;
- ligne 32 : l'annulation d'un rendez-vous ;
- lignes 37-42 : les rendez-vous d'un médecin pour un jour, chacun avec son client et son créneau (l'équivalent des [leftJoinAndSelect] de la version [NestJS]). La table [rv] ne référence pas directement les médecins : le filtre [rv.Creneau.IdMedecin == idMedecin] passe par la navigation [Creneau], et EF Core ajoute la jointure avec [creneaux]. Il faut bien distinguer les deux usages d'une navigation : dans [Where], elle sert à filtrer ; seul [Include] charge l'objet dans le résultat ;
- lignes 47-54 : les rendez-vous d'un client à partir du jour [depuis], avec leur créneau et le médecin de ce créneau : [Include(rv => rv.Creneau)] charge le créneau, puis [ThenInclude(c => c.Medecin)] charge, à partir du créneau, 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 » ;
- lignes 57-58 : le nombre de ces rendez-vous ([SELECT COUNT(*)]), pour la règle « un seul rendez-vous à venir ». [CountAsync] accepte directement la condition, comme [FirstOrDefaultAsync] ;
- lignes 62-63 : 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 ;
- lignes 67-68 : le nombre de rendez-vous d'un médecin, pour refuser la suppression d'un médecin qui en a. Là encore, la navigation [rv.Creneau] produit une jointure ([INNER JOIN creneaux]), sans rien charger : c'est le [innerJoin] de la version [NestJS].
11.11.4. Repositories/UserRepository.cs
- ligne 17 : le compte qui porte ce login, ou [null]. Il sert à la connexion ([AuthService]) et à vérifier qu'un login est libre avant de l'attribuer ;
- lignes 19-26 : la création ou la modification d'un compte. Créer un médecin ou un client, c'est d'abord créer son compte : la personne a besoin de l'identifiant du compte pour sa clé étrangère ;
- ligne 29 : la suppression d'un compte. 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.
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, 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.cs
- ligne 11 : une classe [static] ne s'instancie pas : elle ne fait que regrouper des méthodes, qu'on appelle par le nom de la classe ([Dates.Aujourdhui()]) ;
- ligne 14 : la date du jour, dans le fuseau horaire du serveur. [DateTime.Now] donne la date et l'heure ; [DateOnly.FromDateTime] n'en garde que la date (le type [DateOnly] a été vu à l'exemple 12 ; c'est celui de la colonne [rv.jour]). Deux [DateOnly] se comparent directement avec [<] ou [>=] ; la version [NestJS], qui manipulait des chaînes [aaaa-mm-jj], devait fabriquer elle-même ce format. 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.cs
- ligne 18 : l'exception levée quand une règle de gestion refuse une opération (cf. exemple 28). La classe utilise un constructeur principal (les paramètres [cle] et [parametres] écrits après le nom de la classe) ; [: Exception(cle)] transmet la clé au constructeur de la classe mère [Exception], qui en fait la propriété [Message] (utile dans les traces d'erreur). Différence avec l'exemple 28 : l'exception ne transporte pas un texte en français, mais une clé de traduction ([ERRORS.LOGIN_TAKEN]). 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. chapitre 5) ;
- ligne 21 : la clé de traduction, en lecture seule ([get] sans [set]) ;
- ligne 24 : les paramètres éventuels du message, qui remplaceront des marques comme [{minutes}] dans le texte traduit. [IReadOnlyDictionary] est un dictionnaire qu'on ne peut que lire. L'opérateur [??] fournit un dictionnaire vide quand aucun paramètre n'a été donné : la couche web n'a jamais à tester [null]. Les exceptions levées par [MetierService] n'utilisent en fait que des clés sans paramètre.
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/IMetier.cs
- lignes 17-33 : 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). [required] oblige à fournir titre, nom et prénom ; [init] interdit de les modifier ensuite (cf. exemple 18). Login et mot de passe sont facultatifs ([string?]) : à la création, ils valent par défaut le nom en minuscules ; à la modification, ils ne changent que s'ils ont été saisis. [Version] est la version de la fiche lue lors de l'affichage du formulaire de modification : elle sert au verrou optimiste (cf. plus bas) ;
- ligne 36 : 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). Par convention [.NET], une méthode asynchrone rend une [Task<T>] (l'équivalent d'une [Promise<T>] de [JavaScript]) et son nom se termine par [Async] ;
- lignes 39-53 : les lectures. Un type suivi de [?] ([Task<Client?>]) signale que la méthode peut rendre [null] : personne ne porte cet identifiant. Les commentaires [/// <summary>] sont des commentaires de documentation : [Visual Studio Code] les affiche quand on survole la méthode ;
- lignes 57-64 : les rendez-vous. Le commentaire de [AjouterRvAsync] énumère les clés d'erreur possibles : c'est une partie du contrat, que les contrôleurs doivent connaître ;
- lignes 68-74 : la gestion des médecins (réservée à l'ADMIN). Chaque médecin est créé, modifié et supprimé avec son compte utilisateur ;
- lignes 80-88 : la gestion des clients, avec la même logique. [AjouterClientAsync] sert aussi à l'inscription d'un patient, [ModifierClientAsync] et [SupprimerClientAsync] au patient qui gère son propre compte.
L'implémentation est enregistrée dans [Program.cs] par la ligne [builder.Services.AddScoped<IMetier, MetierService>()] : « quand un constructeur demande un [IMetier], lui fournir un [MetierService] », un par requête ([Scoped]), comme le [DbContext] et les DAO qu'il utilise (cf. exemple 28 et chapitre 6). Avec [NestJS], une interface [TypeScript] disparaissait à la compilation : il fallait un jeton ([METIER_TOKEN]) et [@Inject(METIER_TOKEN)]. En [C#], les interfaces existent à l'exécution : l'interface elle-même sert de jeton. Le seul service qui reçoit un [IMetier] est [ApplicationModel] (dossier [Web]), qui implémente lui aussi [IMetier] : les contrôleurs voient donc le même contrat, enrichi d'une mémoire des listes de médecins et de clients.
11.12.4. Metier/MetierService.cs
C'est le fichier le plus riche de l'application. Nous le montrons en cinq extraits successifs.
11.12.4.1. Les descripteurs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
- lignes 6-29 : le commentaire d'en-tête résume les cinq règles de gestion appliquées par la classe ;
- ligne 34 : le pilote [MySQL] (cf. chapitre 9) : la classe en utilise l'exception [MySqlException], pour reconnaître les violations de contraintes d'unicité ;
- ligne 42 : la classe implémente l'interface : elle doit en fournir toutes les méthodes ;
- ligne 45 : le coût de [bcrypt] (cf. exemple 25), le même que celui des mots de passe du script SQL (préfixe [$2b$10$]) ;
- lignes 49-60 : tout ce qui distingue un médecin d'un client pour les règles de gestion : son DAO, 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 (paramétrées par un type [T]) qui reçoivent un descripteur. La contrainte [where T : Personne, new()] exige que [T] hérite de [Personne] (on pourra lire [Nom], [Prenom], [User]...) et possède un constructeur sans paramètre (on pourra écrire [new T()]). [Func<int, Task<int>>] est le type d'une fonction qui reçoit un [int] et rend une [Task<int>] ; [Func<int, Task>], celui d'une fonction asynchrone qui ne rend rien. Ces paramètres ont une valeur par défaut ([= null]) : ils sont facultatifs ;
- lignes 62-69 : les champs de la classe : le [DbContext], les cinq DAO et les deux descripteurs. [readonly] interdit de les modifier après le constructeur ;
- lignes 74-87 : toutes les dépendances sont fournies par l'injection de dépendances. Le [DbContext] ne sert ici qu'à ouvrir des transactions : les requêtes restent dans la couche DAO. Comme à l'exemple 28, DAO et couche métier reçoivent, pendant une requête, le même [DbContext], donc la même connexion et la même transaction ;
- lignes 89-99 : le descripteur des médecins. Les arguments nommés ([CompterRv:], [CleAvecRv:]...) désignent un paramètre par son nom, ce qui permet de sauter des paramètres facultatifs (le descripteur des clients, plus bas, omet ainsi [CompterRv] et [CleAvecRv]). [rvRepository.CountByMedecinAsync] n'est pas appelée ici : on transmet la méthode elle-même, qui sera appelée plus tard (une méthode peut ainsi servir de valeur de type [Func]). La suppression d'un médecin sera refusée tant qu'il a des rendez-vous ; ses créneaux, eux, seront supprimés avant lui ;
- lignes 101-109 : le descripteur des clients. Pas de [CompterRv] : la suppression d'un client n'est 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.4.2. Les lectures et l'agenda
- lignes 5-22 : les lectures délèguent simplement à la couche DAO. La syntaxe [=> expression] (un corps d'expression) remplace un bloc [{ return expression; }]. Ces méthodes ne sont pas [async] : elles rendent directement la [Task] du DAO, sans l'attendre ;
- lignes 24-25 : les rendez-vous à venir d'un patient : la couche métier fournit la date du jour au DAO ;
- lignes 28-42 : le calcul de l'agenda d'un médecin pour un jour, même algorithme que la version [NestJS]. Un médecin inconnu donne [null] ; sinon on lit ses créneaux et les rendez-vous du jour ;
- ligne 38 : [ToDictionary] range les rendez-vous dans un dictionnaire dont la clé est l'identifiant de leur créneau (l'équivalent de la [Map] de la version [NestJS]). Le dictionnaire évite de parcourir la liste des rendez-vous pour chaque créneau ;
- lignes 39-41 : chaque créneau est associé à son rendez-vous, ou à [null] ([GetValueOrDefault] rend la valeur par défaut, [null], quand la clé est absente). [Select] transforme chaque élément d'une liste (cf. [LINQ], chapitre 9). Le résultat est un objet [AgendaMedecinJour] du dossier [Domain], que la vue de l'agenda affichera.
11.12.4.3. La prise de rendez-vous
- lignes 17-18 : toute la réservation se passe dans une transaction : la méthode passe à [TransactionAsync] (cf. plus bas) une fonction anonyme asynchrone, [async () => { ... }], qui fait le travail ;
- lignes 20-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 25-27 : la ligne du patient est verrouillée : [VerrouillerAsync] exécute [SELECT * FROM clients WHERE id = ... FOR UPDATE] (cf. [Repositories/PersonneRepository.cs]). 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é ;
- lignes 29-31 : un seul rendez-vous à venir par patient ;
- lignes 35-36 : l'insertion. On ne renseigne que les clés étrangères [IdCreneau] et [IdClient] : inutile de rattacher les objets [Creneau] et [Client] pour écrire la ligne [rv]. 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 [TransactionAsync] traduit l'erreur en [MetierException] avec la clé [ERRORS.SLOT_ALREADY_BOOKED] ;
- ligne 39 : l'annulation d'un rendez-vous : un simple [DELETE], sans règle particulière.
11.12.4.4. La gestion des médecins et des clients
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | |
- lignes 6-20 : 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] ;
- lignes 26-50 : la création d'une personne et de son compte ;
- lignes 28-33 : la préparation des données, hors transaction. [Trim] retire les espaces en début et fin de chaîne ; le nom est mis en majuscules. 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 ;
- lignes 37-38 : 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 40-47 : 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 : [BCrypt.Net.BCrypt.HashPassword] en calcule le hachage (cf. exemple 25). [$"{prenom} {nom}"] est une chaîne interpolée : le nom affiché du compte ;
- lignes 49-50 : puis la personne, grâce à la contrainte [new()] : [new T] fabrique un [Medecin] ou un [Client]. Si cette seconde écriture échoue, la transaction annule aussi la première : on n'obtient jamais un compte sans personne ;
- lignes 59-95 : la modification de la personne et de son compte, dans une transaction ;
- ligne 63 : une personne introuvable est une [MetierException], avec la clé du descripteur. [?? throw] a été vu à l'exemple 28 ;
- lignes 70-72 : 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. Si la modification concurrente a lieu après cette lecture, c'est la condition [WHERE version = ...] de l'[UPDATE] qui la détectera (cf. [TraduireErreurBase], plus bas) ;
- lignes 77-80 : on modifie simplement l'objet lu : le [DbContext], qui le suit, enverra les [UPDATE] nécessaires au prochain [SaveChangesAsync]. La vérification des homonymes transmet l'identifiant de la personne : elle a évidemment le droit de garder ses propres prénom et nom ;
- lignes 82-88 : le compte suit la personne (propriété de navigation [User], chargée par le DAO) : son nom affiché est recalculé. Le login ne change que s'il a été saisi et diffère de l'actuel ; il doit alors être libre ;
- lignes 90-92 : le mot de passe ne change que s'il a été saisi ; il est haché à son tour ;
- lignes 94-95 : l'enregistrement du compte et de la personne. Notre [DbContext] augmente la version de chaque entité modifiée (cf. [Data/RdvMedecinsDbContext.cs]) ;
- lignes 107-121 : la suppression, dans une transaction, dans l'ordre imposé par les clés étrangères ;
- lignes 111-113 : 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(id)] appelle la fonction rangée dans le descripteur ;
- lignes 115-117 : la suppression de ce qui référence la personne : les créneaux d'un médecin, les rendez-vous d'un client ;
- lignes 119-120 : la personne, puis son compte, que la personne référençait ;
- ligne 121 : [TransactionAsync] attend une fonction qui rend un résultat : on en rend un, dont personne ne se servira ;
- lignes 127-133 : un homonyme est refusé, sauf s'il s'agit de la personne en cours de modification ([idCourant], [null] pour une création). La méthode est [static] : elle n'utilise aucun champ de l'objet ;
- lignes 137-141 : un login déjà pris, par un médecin, un client ou l'administrateur, est refusé.
11.12.4.5. Les transactions et les contraintes d'unicité
- ligne 15 : toutes les écritures à plusieurs étapes passent par cette méthode générique : [R] est le type du résultat du travail ([Rv], [Medecin], [Client], [bool]), et [Func<Task<R>>] le type d'une fonction asynchrone sans paramètre qui rend un [R]. C'est la méthode [TransactionAsync] de l'exemple 28, complétée ;
- ligne 17 : [BeginTransactionAsync] ouvre une transaction ([START TRANSACTION]). [await using var] déclare une variable qui sera refermée automatiquement à la sortie du bloc qui la contient, quoi qu'il arrive (fin normale ou exception), la version [await] permettant une fermeture asynchrone. Une transaction refermée sans avoir été validée est annulée ([ROLLBACK]) ;
- lignes 20-22 : on exécute le travail ; s'il réussit, [CommitAsync] valide la transaction ([COMMIT]). S'il lève une exception, [MetierException] comprise, on n'atteint jamais [CommitAsync] : tout ce qui a été écrit est annulé ;
- ligne 24 : l'exemple 28 interceptait deux types d'exceptions précis ; ici on intercepte toutes les exceptions, pour faire le ménage avant de les relancer ;
- ligne 29 : le ménage. La base a tout annulé, mais le [DbContext] suit encore des entités qui ne correspondent plus à rien : par exemple le médecin dont l'insertion a échoué reste à l'état [Added]. Le [DbContext] vivant toute la requête, il serait renvoyé en base au prochain [SaveChangesAsync] de cette requête. [ChangeTracker.Clear()] fait oublier au [DbContext] toutes les entités qu'il suivait ;
- lignes 30-32 : si l'erreur est une erreur de la base connue, on lève à sa place la [MetierException] correspondante. [is { } erreurMetier] est un filtrage par motif : il teste que la valeur n'est pas [null] et la range dans la variable [erreurMetier] ;
- ligne 34 : sinon, l'exception d'origine est relancée telle quelle. [throw;] seul, dans un [catch], conserve sa pile d'appels intacte (celle qui dit où l'erreur s'est produite), alors que [throw e;] la réinitialiserait. Une [MetierException] levée par le travail repasse ainsi, inchangée, jusqu'au contrôleur ; toute autre erreur est une anomalie, qui produira la page d'erreur 500 ;
- lignes 51-56 : à 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. [new()] sans nom de type est permis quand le type est déjà écrit à gauche ; [["cle"] = valeur] remplit le dictionnaire ;
- lignes 59-77 : la traduction d'une erreur de la base en [MetierException], ou [null] si l'erreur n'est pas reconnue ;
- lignes 62-64 : l'[UPDATE ... WHERE id = ... AND version = ...] n'a modifié aucune ligne : EF Core lève une [DbUpdateConcurrencyException] (verrou optimiste, cf. exemple 28). Ce test vient en premier, car [DbUpdateConcurrencyException] hérite de [DbUpdateException] : placé après, le test suivant l'aurait intercepté ;
- ligne 68 : une violation de contrainte d'unicité. L'erreur [MySQL] (code [DuplicateKeyEntry], n° 1062) arrive enveloppée dans une [DbUpdateException], dans sa propriété [InnerException]. Le filtrage par motif vérifie tout en une expression, sur deux niveaux : [e] est une [DbUpdateException], dont l'[InnerException] est une [MySqlException], dont l'[ErrorCode] vaut [DuplicateKeyEntry] ; cette [MySqlException] est rangée dans [erreurSql]. La version [NestJS] testait de même le code [ER_DUP_ENTRY] d'une [QueryFailedError] ;
- ligne 71 : le message de l'erreur cite le nom de la contrainte, par exemple [Duplicate entry 'marie-pelissier' for key 'medecins.unq_medecins_prenom_nom']. L'expression régulière l'extrait : [(?:[^'.]*\.)?] saute le préfixe facultatif [medecins.] (écrit par [MySQL] 8, pas par [MariaDB]), et le groupe [([^']+)] capture le nom jusqu'à l'apostrophe. Le [@] devant la chaîne en fait une chaîne verbatim, où [\] n'est pas un caractère d'échappement ;
- lignes 72-74 : si le nom a été trouvé et figure dans le dictionnaire, [TryGetValue] range la clé de traduction dans [cle], et l'erreur devient une [MetierException]. Toute autre erreur, y compris une violation d'une contrainte absente du dictionnaire, est rendue non traduite : c'est une vraie anomalie.
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. Le contrôleur [AuthController], commenté à la fin de cette section, assemble ces pièces pour la page de connexion.
11.13.1. Auth/UtilisateurConnecte.cs
- lignes 24-29 : les noms des claims du jeton, réunis dans des constantes : une faute de frappe dans [ClaimsJwt.Nom] est signalée par le compilateur, alors qu'une faute dans la chaîne ["nom"] ne serait découverte qu'à l'exécution ;
- ligne 33 : 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 de la liste de couples de chaînes que sont les claims. C'est l'équivalent de l'objet [req.user] fabriqué par la stratégie [Passport] du projet [NestJS] ;
- lignes 39-42 : une méthode d'extension (cf. exemple 06) sur [ClaimsPrincipal], le type de la propriété [User] des contrôleurs et des vues (cf. exemple 25). Les pages écrivent simplement [User.Utilisateur()] ;
- lignes 44-48 : la méthode rend [null] si la requête n'est pas authentifiée, ou si les claims ne sont pas ceux attendus. [Identity?.IsAuthenticated] vaut [null] si [Identity] est absente : la comparaison [!= true] traite ce cas comme « non authentifié ». [int.TryParse] et [Enum.TryParse<Role>] tentent une conversion et rendent [false] en cas d'échec, au lieu de lever une exception ; le résultat est rangé dans la variable déclarée par [out var] ;
- lignes 50-54 : [FindFirstValue] rend la valeur du premier claim du type demandé, ou [null] ; l'opérateur [??] la remplace alors par une chaîne vide.
11.13.2. Auth/AuthService.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | |
Commentons ce code :
- ligne 38 : le service reçoit par injection le DAO des comptes utilisateurs ([UserRepository], commenté avec la couche DAO) et la configuration de l'authentification. [IOptions<AuthOptions>] donne la section [Auth] de [appsettings.json] sous la forme d'un objet [AuthOptions] (dossier [Config]) : sa propriété [Value] contient la clé secrète, la durée de vie du jeton et l'option [CookieSecure]. Le service est enregistré [Scoped] dans [Program.cs], comme le DAO dont il se sert (cf. chapitre 6) ;
- ligne 41 : le nom du cookie qui transporte le jeton ;
- lignes 47-56 : la clé de signature (cf. exemple 25). Nouveauté : une clé trop courte est refusée par une exception claire. La méthode est statique : elle ne dépend que de son paramètre, et elle est appelée au démarrage de l'application, par [AuthentificationJwt] (fichier suivant), à un moment où aucun service n'existe encore. Comme la vérification de longueur a lieu à cet instant, une mauvaise configuration empêche l'application de démarrer, au lieu de faire échouer la première connexion ;
- lignes 62-69 : le compte si le login et le mot de passe sont corrects, [null] sinon (cf. exemple 25). La méthode est asynchrone : la recherche du compte est une requête à la base de données (cf. chapitre 9) ;
- lignes 78-92 : le jeton, fabriqué comme dans l'exemple 25. Il contient l'identifiant du compte, le login, le nom affiché dans la barre de navigation et le rôle ; sa durée de vie vient de la configuration (deux heures par défaut) ;
- lignes 94-100 : 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. [MaxAge] fait disparaître le cookie en même temps que le jeton expire ;
- lignes 104-105 : la déconnexion efface le cookie. Pour que le navigateur reconnaisse le cookie à effacer, le chemin ([Path]) doit être celui du dépôt. La méthode est statique : elle sert aussi au gestionnaire [JwtBearer] (fichier suivant) et au filtre d'exceptions [HtmlExceptionFilter] (dossier [Web]), qui n'ont pas d'instance de [AuthService] sous la main.
11.13.3. Auth/AuthentificationJwt.cs
Dans l'exemple 25, la configuration de [JwtBearer] était écrite dans [Program.cs]. Elle est ici rangée dans une méthode d'extension, que [Program.cs] appelle en une ligne : [builder.Services.AddAuthentificationJwt(authOptions)]. Commentons ce code :
- lignes 32-34 : la méthode d'extension (cf. exemple 06). Elle reçoit directement un objet [AuthOptions], lu dans la configuration par [Program.cs] : au moment où l'on enregistre les services, l'injection de dépendances n'est pas encore disponible, on ne peut donc pas demander un [IOptions<AuthOptions>] ;
- lignes 37-42 : le schéma d'authentification [JwtBearer], qui garde les noms de claims du jeton (cf. exemple 25) ;
- lignes 44-54 : les vérifications faites sur chaque jeton : la signature, avec la clé fabriquée par [AuthService.CleDeSignature], et la date d'expiration. Nouveauté par rapport à l'exemple 25 : [ClockSkew = TimeSpan.Zero]. Par défaut, [JwtBearer] accepte encore un jeton pendant cinq minutes après son expiration, pour tolérer un léger désaccord entre les horloges du serveur qui fabrique le jeton et de celui qui le vérifie. Ici, c'est le même serveur : un jeton expiré est refusé tout de suite ;
- lignes 57-58 : le claim [nom] fournit [User.Identity.Name], le claim [role] est celui que vérifient [[Authorize(Roles = "ADMIN")]] et [User.IsInRole] (cf. exemple 26) ;
- lignes 64-67 : le jeton est lu dans le cookie, et non dans l'entête [Authorization] (cf. exemple 25) ;
- lignes 71-82 : une page protégée demandée sans jeton valide renvoie vers la page de connexion (cf. exemple 25). Nouveauté (lignes 77-80) : si la requête portait un cookie de jeton, c'est que l'utilisateur s'était connecté, mais que son jeton a expiré (ou a été falsifié). On efface ce cookie devenu inutile, et on dépose un message flash (cf. exemple 20 ; la classe [Flash] est commentée avec le dossier [Web]) : la page de connexion affichera « votre session a expiré », au lieu de laisser l'utilisateur se demander pourquoi il se retrouve là. Le message est une clé de traduction (cf. exemple 22).
Le refus pour rôle insuffisant (réponse 403) n'a pas besoin d'événement : comme dans l'exemple 26, la réponse vide est transformée en page d'erreur par [UseStatusCodePagesWithReExecute]. Dans le projet [NestJS], ce travail était partagé entre la stratégie [Passport] [jwt] (lire et vérifier le jeton), la garde [JwtAuthGuard] (exiger un jeton) et le filtre d'exceptions (transformer le refus en redirection) ; ici, le gestionnaire [JwtBearer], ses événements et l'attribut [[Authorize]] suffisent.
11.13.4. Auth/SessionInvalideException.cs
- ligne 13 : 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 [PatientConnecteAsync] du fichier [Web/Outils.cs]) et lèvent alors cette exception ; le filtre d'exceptions [HtmlExceptionFilter] (cf. exemple 24 pour les filtres) l'intercepte, efface le cookie et renvoie vers la page de connexion, avec le message « session expirée ». La classe a un constructeur principal sans paramètre, qui transmet son message au constructeur de la classe mère [Exception].
11.13.5. Auth/CaptchaService.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | |
Le fichier contient deux classes : le service, identique à celui de l'exemple 27 à un garde-fou près, et la classe [DessinCaptcha] qui fabrique l'image, déjà commentée dans l'exemple 27 (fichier [Auth/DessinCaptcha.cs]). Commentons ce code :
- ligne 44 : un captcha, tel que la page le reçoit : l'identifiant, à placer dans un champ caché du formulaire, et l'image [SVG] ;
- lignes 49-52 : 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 ;
- ligne 55 : identifiant → (réponse attendue, date d'expiration). Le couple est un tuple dont les éléments sont nommés ([Texte], [Expire]). Le service est un singleton, utilisé par des requêtes simultanées : d'où le [ConcurrentDictionary] (cf. exemple 27) ;
- lignes 57-63 : le ménage, puis un texte de cinq caractères tiré au hasard, mémorisé sous un identifiant aléatoire ([Guid.NewGuid()], impossible à deviner), et dessiné ;
- lignes 70-77 : la vérification, identique à celle de l'exemple 27 : le captcha est consommé dans tous les cas ([TryRemove]), puis on vérifie qu'il n'a pas expiré et que la saisie, débarrassée de ses espaces, est la bonne, sans tenir compte des majuscules. [TryRemove] est atomique : si deux requêtes présentent le même captcha au même instant, une seule le retire ;
- lignes 80-87 : les captchas expirés sont oubliés ; si le plafond est atteint, on retire aussi des captchas encore valides, jusqu'à repasser sous le plafond. Un [ConcurrentDictionary] peut être modifié pendant qu'on le parcourt, ce qu'interdit un [Dictionary] ordinaire (cf. [LimiteurTentativesService], plus bas). En revanche, il ne conserve pas l'ordre d'insertion : les captchas ainsi retirés ne sont pas forcément les plus anciens, alors que la [Map] du projet [NestJS] permettait d'oublier d'abord les plus anciens. Le cas ne se produit qu'en cas d'attaque : l'approximation suffit ;
- ligne 96 : le dessin de l'image. [internal] : la classe n'est visible que dans l'application elle-même ;
- lignes 104-136 : 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. Les crochets [[...]] sont des expressions de collection ([C#] 12), une écriture compacte des tableaux ;
- ligne 139 : les caractères utilisables, c'est-à-dire les clés de la police. [..] étale une collection dans une autre ;
- lignes 146-147 et lignes 149-150 : le tirage au hasard utilise [RandomNumberGenerator], un générateur cryptographique, dont les tirages sont imprévisibles ;
- lignes 152-194 : l'image est un texte [SVG], assemblé dans un [StringBuilder] (une chaîne qu'on allonge sans la recopier à chaque ajout). Chaque caractère (lignes 160-181) 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 185-190) ;
- ligne 199 : les nombres du [SVG] s'écrivent avec un point décimal. [CultureInfo.InvariantCulture] impose cette écriture quelle que soit la langue du serveur : en français, [ToString()] écrirait une virgule (cf. chapitre 5).
11.13.6. Auth/LimiteurTentativesService.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | |
Par rapport à 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 [appsettings.json]. Commentons ce code :
- ligne 31 : [IOptions<ProtectionOptions>] donne la section [Protection] de [appsettings.json] (dossier [Config]) : 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 33-37 : un compteur : le nombre de tentatives, le début de la fenêtre de comptage, la fin du blocage ([DateTime.MinValue], la plus petite date possible, s'il n'y a pas de blocage). C'est une classe imbriquée, privée : elle n'existe que pour ce service. C'est une classe, et non un [record] ni une structure : un compteur lu dans le dictionnaire est modifié sur place ;
- lignes 42-43 : un [Dictionary] ordinaire, protégé par un verrou (cf. exemple 27). Un [ConcurrentDictionary] ne suffirait pas : 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. Chaque méthode publique fait donc son travail à l'intérieur d'un bloc [lock (verrou)] ;
- lignes 45-47 : les seuils, lus une fois pour toutes. Le paramètre du constructeur principal est utilisable dans l'initialisation des champs ;
- lignes 52-56 : 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 60-65 : 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 70-74 : une connexion réussie remet à zéro le compteur de ce login (pas celui de l'adresse IP) ;
- lignes 80-84 et lignes 89-93 : 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 ;
- lignes 100-107 : les minutes de blocage restantes, arrondies au nombre entier supérieur ([Math.Ceiling]) : il reste « 1 minute », et non « 0 minute », quand il reste 20 secondes. Ces méthodes privées ne prennent pas le verrou : elles sont toujours appelées par une méthode publique qui le détient déjà ;
- lignes 111-125 : 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 130-140 : le ménage des compteurs devenus inutiles (ni bloqués, ni récents), fait seulement quand le dictionnaire dépasse mille entrées. On parcourt une copie des entrées ([ToList()]) : un [Dictionary] ne peut pas être modifié pendant qu'on le parcourt ;
- lignes 145-147 : 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.
11.13.7. Auth/PasseInscriptionService.cs
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] (commenté avec les contrôleurs) :
- ligne 29 : le nom du cookie qui transporte le laissez-passer ;
- lignes 31-35 : 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 38-43 : 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 47-48 : le formulaire d'inscription n'est affiché, et accepté, qu'avec un laissez-passer valide. La vérification ne le consomme pas ([TryGetValue], et non [TryRemove]) : l'utilisateur peut renvoyer le formulaire plusieurs fois s'il a fait des erreurs de saisie ;
- lignes 51-55 : un compte créé consomme le laissez-passer : un captcha résolu donne droit à un compte, pas plus ;
- lignes 60-65 : les options du cookie. [new()] crée un objet du type annoncé par la méthode ([CookieOptions]). L'option [Path = "/inscription"] limite l'envoi du cookie aux URL qui commencent par [/inscription] : les autres requêtes ne le transportent pas inutilement ;
- lignes 68-75 : le ménage, identique à celui du captcha.
11.13.8. Controllers/AuthController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | |
Commentons ce code :
- lignes 31-34 : le contrôleur reçoit les trois services dont il a besoin. Aucun attribut [[Authorize]] : ses pages sont accessibles sans être connecté ;
- ligne 37 : la page d'accueil dépend du rôle : « Mon espace » pour un patient, l'agenda pour les autres ;
- lignes 39-45 : la racine du site redirige vers la page d'accueil du rôle, ou vers la page de connexion. Le middleware d'authentification s'exécute pour toutes les requêtes, protégées ou non : si le cookie contient un jeton valide, [User] est rempli, même ici. Le projet [NestJS] devait, lui, vérifier le jeton lui-même (méthode [lireJeton]), la garde n'étant pas appliquée à cette route ;
- lignes 50-59 : un utilisateur déjà connecté est envoyé à sa page d'accueil. Le paramètre facultatif [login], venu de l'URL (cf. exemple 07), préremplit le champ après la création d'un compte. [login[..n]] (opérateur de plage) en garde les [n] premiers caractères : on borne sa longueur à 30, car une donnée venue de la requête n'est jamais utilisée sans précaution ;
- lignes 65-70 : 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 déclarés [string?] : un champ absent donne [null], et c'est l'action qui décide quoi en faire ;
- lignes 72-74 : un login absent devient une chaîne vide ; [password ??= ""] n'affecte la chaîne vide que si [password] est [null] ;
- lignes 77-80 : 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 une clé de traduction, avec un paramètre : le nombre de minutes à attendre ;
- lignes 84-87 : 2. le captcha. Un mauvais captcha compte comme un échec (cf. exemple 27) ;
- lignes 91-95 : 3. seulement alors, le mot de passe. Un login ou un mot de passe vide n'est pas soumis au calcul [bcrypt], coûteux. Login inconnu ou mot de passe faux : le même message, volontairement vague (cf. exemple 25), et un échec de plus ;
- lignes 98-100 : le succès : compteur remis à zéro, jeton déposé dans le cookie, redirection vers la page d'accueil du rôle ;
- lignes 105-109 : la déconnexion, en POST (cf. exemple 25) ;
- ligne 115 : l'adresse IP du client, fournie par la connexion réseau ([RemoteIpAddress], qui peut être [null], d'où [?.] et [??]). 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 le middleware à activer dans ce cas ;
- lignes 118-125 : la page de connexion, toujours avec un nouveau captcha : chaque image ne sert qu'une fois. Son modèle, [LoginModele] (dossier [Models]), contient le titre de la page (une clé de traduction), le login saisi (jamais le mot de passe), le captcha, et l'erreur éventuelle, clé de traduction et paramètres. [erreur] et [erreurParams] sont des paramètres facultatifs : ils valent [null] quand l'appel ne les fournit pas.
11.14. La couche web : les services communs
Avant les contrôleurs et les vues, voici ce qu'ils ont en commun : la traduction, la classe mère des vues, les messages flash, le filtre d'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 16 à 24.
11.14.1. Web/Traducteur.cs
C'est le service de traduction de l'exemple 22, enregistré comme singleton dans [Program.cs] : les dictionnaires [i18n/fr.json] et [i18n/en.json] sont les mêmes fichiers que ceux de la version [NestJS], lus une seule fois au démarrage. Une seule différence :
- lignes 65-67 : une clé absente du dictionnaire anglais est cherchée dans le dictionnaire français ([LangueParDefaut]) ; absente partout, c'est la clé elle-même qui est affichée. Les deux opérateurs [??] s'enchaînent : on prend la première valeur qui n'est pas [null].
11.14.2. Web/PageVue.cs
Commentons ce code :
- ligne 25 : la classe mère de toutes les vues, désignée par la directive [@inherits] de [Views/_ViewImports.cshtml], comme à l'exemple 22. Elle joue le rôle de [res.locals] dans la version [NestJS] : elle met à la disposition de chaque vue ce dont toutes les pages ont besoin ;
- lignes 29-30 : le service de traduction, injecté dans la propriété (cf. exemple 22) ;
- lignes 39-40 : la méthode [T] de l'exemple 22 : [@T("CLE", ("nom", valeur))]. Le texte rendu est échappé par [Razor] : un nom saisi par un utilisateur ne peut pas injecter de HTML dans la page ;
- ligne 43 : une seconde version de [T], qui reçoit directement un dictionnaire : c'est la forme sous laquelle arrivent les paramètres d'un message flash ou d'une [MetierException]. Deux méthodes de même nom, qui diffèrent par leurs paramètres, sont des surcharges : le compilateur choisit la bonne selon les arguments ;
- ligne 45 : la langue courante ([fr] ou [en]), pour l'attribut [lang] de la page et les boutons FR / EN ;
- ligne 47 : l'utilisateur connecté, pour la barre de navigation, ou [null] s'il n'y en a pas. [Context.User] est l'utilisateur de la requête, reconstruit à partir du jeton JWT par le middleware d'authentification (cf. chapitre 8) ; la méthode [Utilisateur()] (fichier [Auth/UtilisateurConnecte.cs]) en extrait le compte. Dans la version [NestJS], il fallait un intercepteur pour recopier [req.user] dans [res.locals] après les gardes ; ici, la propriété est calculée au moment où la vue la lit, donc bien après l'authentification ;
- ligne 49 : le message flash de la requête (cf. [Web/Flash.cs], plus bas) ;
- lignes 51-54 : le chemin de l'URL demandée, et la méthode qui rend [active] si la page courante est sous un chemin donné : c'est ainsi que la barre de navigation met en valeur le menu courant, par exemple [@Actif("/mon-")] (cf. exemple 16). [StringComparison.Ordinal] compare les caractères un à un, sans tenir compte de la langue ;
- lignes 60-70 : l'utilisateur connecté tel que les vues le voient : login, nom, rôle, et deux booléens [EstAdmin] et [EstPatient] qui évitent aux vues de comparer des rôles. La méthode statique [De] fabrique cet objet à partir du compte connecté, ou rend [null] ; l'opérateur [? :] choisit entre les deux. Les arguments [EstAdmin:] et [EstPatient:] sont nommés : on écrit le nom du paramètre devant la valeur, ce qui rend l'appel plus lisible.
11.14.3. Web/Flash.cs
Commentons ce code :
- lignes 23-26 : les deux types de messages : un succès, affiché en haut de la page (vue partielle [_Flash.cshtml]), une erreur, affichée en bas (vue partielle [_Erreurs.cshtml]) ;
- ligne 30 : un message flash est 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 32 : une classe statique, qui ne contient que des fonctions (cf. [Web/Outils.cs]). [TempData] (cf. exemple 20) fait l'essentiel du travail : c'est lui qui conserve la valeur dans un cookie chiffré jusqu'à la requête suivante, et l'efface une fois lue. Dans la version [NestJS], ce cookie de courte durée avait été programmé à la main ;
- lignes 43-51 : dépose un message. [TempData] ne sait conserver que des valeurs simples (textes, nombres) : le message est rangé sous forme de texte JSON ([JsonSerializer.Serialize]). La méthode reçoit le [HttpContext] et non la propriété [TempData] d'un contrôleur : elle peut ainsi être appelée hors de MVC, par exemple par l'authentification JWT, quand une session a expiré (cf. [Auth/AuthentificationJwt.cs]). Hors de MVC, personne n'enregistrerait [TempData] à la fin de la requête : [Save()] écrit le cookie tout de suite ;
- lignes 60-79 : lit le message de la requête. Lire [TempData] le marque comme consommé ; or la page le demande deux fois (en haut pour un succès, en bas pour une erreur). Le premier appel range donc le résultat dans [HttpContext.Items], le dictionnaire de portée requête (cf. chapitre 6), où le second le retrouve (ligne 62) ;
- lignes 71-73 : [Deserialize] reconstruit l'objet [MessageFlash] à partir du JSON. Un texte illisible ne doit pas faire échouer la page : l'exception est interceptée et le message ignoré ;
- lignes 82-83 : le [TempData] de la requête, obtenu auprès du service [ITempDataDictionaryFactory]. [RequestServices] donne accès à l'injection de dépendances depuis une classe statique, qui ne peut pas recevoir de services par son constructeur.
11.14.4. Web/HtmlExceptionFilter.cs
Commentons ce code :
- ligne 25 : un filtre d'exceptions, comme à l'exemple 24, ajouté comme filtre global dans [Program.cs]. Dans la version [NestJS], un seul filtre transformait toutes les erreurs en pages HTML. Ici, le travail est partagé avec deux middlewares du framework, configurés dans [Program.cs] : [UseStatusCodePagesWithReExecute] remplace les réponses 400, 403 et 404 sans contenu par la page d'erreur correspondante (cf. exemple 24), et [UseExceptionHandler("/erreur/500")] intercepte toute autre exception : l'anomalie est inscrite dans le journal du serveur, avec sa pile d'appels, mais l'utilisateur ne voit que la page d'erreur 500, car ces détails aideraient un attaquant ;
- lignes 29-34 : le filtre ne traite qu'un cas : le compte de l'utilisateur connecté a été supprimé depuis sa connexion, alors que son jeton est encore valide (cf. [Web/Outils.cs], plus bas). On efface le cookie du jeton, on dépose un message flash « session expirée », et on redirige vers la page de connexion. Les autres exceptions ne sont pas marquées comme traitées : elles poursuivent leur chemin jusqu'à [UseExceptionHandler].
11.14.5. Web/ApplicationModel.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | |
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. chapitre 6). La version [NestJS] confiait ce rôle à un seul service, singleton comme tous les services [NestJS]. En [ASP.NET Core], c'est impossible : la couche métier utilise le [DbContext], qui ne vit que le temps d'une requête (service [Scoped], cf. chapitre 9), et un singleton ne peut pas recevoir un service [Scoped]. On sépare donc les deux rôles en deux classes.
Commentons ce code :
- ligne 39 : le cache lui-même, enregistré comme singleton ([AddSingleton<CacheListes>()] dans [Program.cs]). Il ne contient que les deux listes ;
- lignes 41-42 : [ASP.NET Core] traite les requêtes en parallèle, sur plusieurs fils d'exécution ([threads]), alors que [Node.js] n'en a qu'un. 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 fils d'exécution voient aussitôt la nouvelle liste. [IReadOnlyList] ne propose que la lecture : le compilateur interdit d'ajouter ou de retirer un élément ;
- ligne 60 : la façade, enregistrée comme service [Scoped] ([AddScoped<ApplicationModel>()]). Elle reçoit la couche métier de la requête en cours et le cache partagé. Elle implémente la même interface [IMetier] que la couche métier : pour les contrôleurs, elle est la couche métier, et c'est elle seule qu'ils reçoivent ;
- lignes 65-68 : le chargement initial des deux listes. Il est appelé une fois, par [Program.cs], avant le démarrage du serveur. Un service [Scoped] n'existe que dans une portée : [Program.cs] en crée une pour l'occasion ([CreateScope]). Si la base est inaccessible, l'exception arrête l'application avec un message explicite, ce qui vaut mieux qu'un serveur démarré avec des listes vides ;
- lignes 71-73 : le rechargement d'une liste : on lit la liste complète dans la base, et on remplace celle du cache ;
- lignes 77-79 : les listes sont servies depuis la mémoire. L'interface [IMetier] annonce une méthode asynchrone : [Task.FromResult] fabrique une tâche déjà terminée, qui porte le résultat. [ToList()] rend une copie de la liste : l'appelant peut la modifier sans toucher au cache ;
- ligne 83 : le médecin d'un compte, cherché dans le cache : un médecin connecté voit d'emblée son propre agenda ;
- lignes 87-110 : les autres demandes sont relayées telles quelles à la couche métier ;
- ligne 104 : 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 114-151 : toute écriture sur les médecins ou les clients est suivie du rechargement de la liste concernée. Puisque ce service 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.6. Web/Validation.cs
Commentons ce code :
- lignes 20-31 : transforme [ModelState] (cf. exemples 07 et 19) en un dictionnaire simple « champ → clé du message ». Seul le premier message de chaque champ est gardé : un seul message s'affiche sous le champ. Le dictionnaire ignore les majuscules (ligne 22) : [ModelState] nomme les champs d'après les propriétés ([Nom], [Login]), tandis que les vues les demandent d'après les champs HTML ([nom], [login]) ;
- lignes 27-28 : par précaution, une erreur sans message (une erreur produite par une exception, par exemple) reçoit le message générique [ERRORS.INVALID_FORM] ;
- lignes 38-42 : 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. La syntaxe [["clé"] = valeur] remplit le dictionnaire dès sa création ;
- lignes 53-64 : prépare un modèle de formulaire pour son affichage ou son réaffichage : les messages sous les champs ([Erreurs]), les champs à encadrer en rouge ([ChampsInvalides] : ceux qui ont un message, plus les [autresChamps], ligne 61), le message du bas de la page et ses paramètres. Faute de message explicite, des erreurs de champ donnent le message « Le formulaire contient des erreurs » (ligne 62). La méthode est générique : la contrainte [where T : FormulaireVue] (ligne 58) accepte tout modèle dérivé de [FormulaireVue] (cf. [Models/ModelesVues.cs]), et elle rend le modèle avec son type exact, prêt à être passé à [View]. Les paramètres ont des valeurs par défaut ([= null]) : l'appelant ne donne que ceux dont il a besoin.
11.14.7. Web/Outils.cs
Commentons ce code :
- ligne 24 : 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] : la même fonction sert aux deux ;
- ligne 30 : le format des dates dans les URL et dans le champ [<input type="date">] ;
- lignes 34-35 : vrai si le texte est une date au format [yyyy-MM-dd] qui existe : [2026-02-30] est refusé (cf. exemple 12). Le résultat est rendu dans le paramètre [out jour]. [CultureInfo.InvariantCulture] rend l'analyse indépendante de la langue de la requête ;
- ligne 37 : l'opération inverse : une date vers le texte [yyyy-MM-dd] ;
- lignes 40-42 : une heure au format [8:05] (le format [00] impose deux chiffres pour les minutes), et l'horaire d'un créneau : [8:00 - 8:20] ;
- lignes 47-48 : un jour en toutes lettres, selon la culture de la requête, fixée par le middleware de localisation (cf. exemple 22) : « mercredi 23 septembre 2026 » ou « Wednesday 23 September 2026 » ;
- lignes 54-55 : un identifiant envoyé par le navigateur (dans l'URL ou un champ de formulaire) : un entier strictement positif, ou [null]. [NumberStyles.None] n'accepte que des chiffres : ni signe, ni espaces, ni séparateur de milliers. Le contrôleur n'a plus qu'à tester [null] ;
- lignes 70-73 : 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 le filtre [HtmlExceptionFilter] vu plus haut, qui renvoie à la page de connexion. [?? throw] lève l'exception quand la valeur de gauche est [null] (cf. chapitre 9).
11.15. Les modèles des formulaires et des vues
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, et les modèles de vue, préparés par les contrôleurs pour les vues. Les premiers correspondent aux DTO de la version [NestJS], les seconds aux objets passés à [res.render].
11.15.1. Models/Formulaires.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | |
Commentons ce code :
- ligne 43 : les titres possibles ;
- ligne 46 : 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 tiret et le souligné ;
- ligne 49 : la longueur minimale d'un mot de passe choisi par un patient. C'est une constante ([const]) : elle peut donc servir d'argument à un attribut, comme [[MinLength(ReglesSaisie.MotDePasseMin)]] plus bas ;
- ligne 52 : le nettoyage d'une saisie : les espaces de début et de fin sont retirés, et un champ fait seulement d'espaces devient [null], c'est-à-dire absent ;
- lignes 59-62 : un attribut de validation écrit par nos soins : la valeur doit figurer dans une liste. Il suffit de dériver de [ValidationAttribute] et de redéfinir [IsValid]. [null] est accepté : c'est le rôle de [[Required]] de refuser un champ absent. On l'utilise sous le nom [[Parmi]] : par convention, le suffixe [Attribute] peut être omis ;
- ligne 66 : les champs d'identité, communs aux trois formulaires. Toutes les propriétés sont de type [string?] : un champ peut toujours manquer dans ce qu'envoie le navigateur, et c'est [[Required]] qui l'exige. Les messages d'erreur sont des clés de traduction (cf. exemple 22) ;
- lignes 68-70 : le titre, obligatoire et choisi dans la liste. Les valeurs sont répétées dans [[Parmi(...)]] : l'argument d'un attribut doit être connu à la compilation, ce qui n'est pas le cas du tableau [ReglesSaisie.Titres] (un champ [static readonly], créé à l'exécution) ;
- lignes 73-80 et lignes 83-90 : le prénom et le nom, obligatoires, de 20 caractères au plus (la taille des colonnes SQL). Ces propriétés ont un corps : leur valeur est rangée dans un champ privé, et le [set] la nettoie au passage. La liaison de modèle affectant les propriétés avant la validation, c'est la valeur nettoyée qui est validée. C'est l'équivalent des [@Transform] de la version [NestJS] ; [ASP.NET Core MVC] transforme déjà, de lui-même, un champ vide en [null] ;
- ligne 95 : le formulaire de l'ADMIN, pour ajouter ou modifier un médecin ou un client. La classe hérite des champs d'identité et de leurs attributs de validation ;
- lignes 97-104 : le login, facultatif ici, mais au bon format s'il est présent (ligne 100) ; il est nettoyé et mis en minuscules. [?.] n'appelle [ToLowerInvariant] que si la valeur n'est pas [null] ;
- lignes 108-109 : le mot de passe, facultatif lui aussi, de 72 caractères au plus : l'algorithme [bcrypt], qui le chiffre, ignore les caractères suivants. Un mot de passe n'est pas nettoyé : ses espaces en font partie ;
- ligne 114 : la version de la fiche, dans un champ caché du formulaire de modification (verrou optimiste, cf. exemple 28). Un champ absent ou vide donne [null] ; un texte qui n'est pas un nombre produit une erreur de liaison, dont le message est configuré dans [Program.cs] (cf. exemple 19) ;
- ligne 118 : la création d'un compte par un patient : login, mot de passe (de 6 à 72 caractères, ligne 131) et confirmation (lignes 135-136) sont obligatoires. L'égalité du mot de passe et de sa confirmation met en jeu deux champs : elle est vérifiée par le contrôleur ([Controllers/InscriptionController.cs]) ;
- ligne 140 : la modification de son compte par un patient. Le nouveau mot de passe est facultatif (ligne 153) : vide, il reste inchangé ; sa confirmation (ligne 157) n'est donc pas exigée ici. Le mot de passe actuel, en revanche, est obligatoire pour toute modification (lignes 160-161), et la version de la fiche voyage dans un champ caché (ligne 163).
11.15.2. Models/ModelesVues.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | |
Les modèles de vue suivent la règle déjà appliquée avec [Handlebars] : 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. Chaque vue déclare le type de son modèle ([@model AgendaModele], cf. exemple 13) : le compilateur vérifie chaque [@Model.Xxx], et une faute de frappe devient une erreur de compilation.
Commentons ce code :
- lignes 22-31 : ce que toutes les pages ont en commun, lu par le gabarit [_Layout.cshtml] : la clé de traduction du titre de la page (ligne 25), et le message d'erreur de la page, affiché en bas, avec ses paramètres. La classe est abstraite : chaque page a son propre modèle, qui en dérive ;
- ligne 35 : une option d'une liste déroulante (un médecin, un patient), déjà marquée comme choisie ou non (cf. exemple 18) ;
- lignes 38-41 : la page de connexion : le login déjà saisi et le captcha à afficher. Le mot-clé [required] oblige celui qui crée l'objet à donner une valeur à la propriété : l'oubli est une erreur de compilation ;
- lignes 45-54 : 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), le bouton Réserver affiché ou non, le bouton Annuler affiché ou non ([IdRv] renseigné). Les propriétés [init] ne peuvent recevoir une valeur qu'à la création de l'objet ;
- ligne 57 : l'agenda d'un médecin pour un jour : le médecin, le jour en toutes lettres, et les lignes ;
- lignes 59-66 : 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 69-79 : 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 76), et le message qui explique à un patient pourquoi il ne peut pas réserver ;
- lignes 85-91 : 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 ;
- ligne 95 : la classe mère des modèles des trois formulaires « personne », celle qu'exige [Validation.Preparer] : les valeurs des champs, les messages sous les champs (ligne 100), et les champs à encadrer en rouge (ligne 104). Un [HashSet] est un ensemble : chaque élément n'y figure qu'une fois, et le test d'appartenance est rapide ;
- lignes 106-108 : deux méthodes pour les vues : le message d'un champ (ou [null]), et la classe [Bootstrap] [is-invalid] qui encadre un champ en rouge. Les vues écrivent par exemple [@Model.ClasseInvalide("login")] ;
- lignes 110-111 : les options de la liste des titres, celle qui est choisie étant marquée (cf. exemple 18). Chaque option est un tuple [(Valeur, Selectionne)] ;
- lignes 114-118 : 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 121-128 : 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 ;
- lignes 134-142 : 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 144-150 : 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 154-159 : l'espace du patient : son nom et ses rendez-vous à venir, chacun avec un lien vers l'agenda du jour ;
- lignes 163-166 : la page d'erreur : le code HTTP et la clé du message, par défaut celle d'une erreur du serveur.
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 typé, ou bien redirige. 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.cs
C'est, à une ligne près, le contrôleur de l'exemple 22 : même route [/langue/{langue}], même cookie du middleware de localisation, même retour à la page d'origine par l'entête [Referer]. Deux précisions :
- ligne 34 : l'option [Path = "/"] rend le cookie valable pour toutes les adresses du site, quelle que soit la page d'où l'on a changé de langue ;
- ligne 49 : si la page d'origine est elle-même une page [/langue/...], on revient à la racine du site plutôt que d'y retourner, ce qui ferait tourner le navigateur en rond.
11.16.2. Controllers/AgendaController.cs
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
- 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 18-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 55-57 : [[Authorize]] sur la classe : toutes les actions exigent un utilisateur connecté, quel que soit son rôle (cf. exemple 25). [[Route("agenda")]] est le préfixe de toutes les routes du contrôleur (cf. exemple 02). Le contrôleur reçoit par injection (cf. exemple 06) le service [ApplicationModel], par son constructeur principal : les paramètres écrits entre parenthèses après le nom de la classe sont accessibles dans toutes ses méthodes ;
- lignes 60-63 : ce que le contrôleur sait de l'utilisateur connecté, calculé une fois par requête (cf. [DroitsAsync], plus bas). Un [record] privé, 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).
11.16.2.2. GET /agenda
- lignes 6-7 : la route [GET /agenda] (préfixe du contrôleur, suivi d'une chaîne vide). L'action n'a aucun paramètre : elle lit elle-même l'URL par [Request.Query] (cf. exemple 05). La liaison de modèle convertirait un paramètre [int? idMedecin] en [null] aussi bien quand il est absent que quand il est vide ; or ces deux cas doivent être distingués (voir plus bas) ;
- lignes 10-11 : l'utilisateur connecté et ses droits. [Utilisateur()] est une méthode d'extension (cf. exemple 06) définie dans [Auth/UtilisateurConnecte.cs] : elle lit les claims du jeton et en fait un objet typé. Le [!] affirme au compilateur que le résultat n'est pas [null] : c'est certain, puisque [[Authorize]] a écarté les requêtes non authentifiées ;
- lignes 14-26 : le jour demandé, s'il est valide ; aujourd'hui sinon. [TryGetValue] rend [false] si le paramètre [jour] est absent : c'est alors aujourd'hui, sans message. Présent mais invalide ([jour=2026-02-30]), il produit un message d'erreur. On ne peut réserver qu'à partir d'aujourd'hui ([jourReservable]) ;
- lignes 29-32 : le médecin demandé. [Saisie.EntierPositif] (dossier [Web]) rend un entier strictement positif, ou [null] pour une valeur absente, vide ou invalide. 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é ;
- lignes 35-37 : 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 fonction [Option], en fin de fichier, et l'exemple 18) ;
- lignes 40-46 : l'agenda n'est calculé que si un médecin est choisi et si la date est valide. [idMedecin is int id] teste que la valeur (de type [int?]) n'est pas [null], et range sa valeur dans une nouvelle variable [id], de type [int]. Un identifiant de médecin inconnu donne un message, pas une page d'erreur ;
- lignes 50-56 : l'agenda calculé par la couche métier (un objet [AgendaMedecinJour] du dossier [Domain]) est transformé en lignes de tableau, une par créneau. [var (creneau, rv) = ...] déconstruit un couple de valeurs en deux variables. [moi] dit si le rendez-vous est celui du patient connecté ;
- lignes 58-59 : 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 63 : 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 65-67 : 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 70 : l'agenda complet : le médecin, le jour, les lignes.
11.16.2.3. La fenêtre de réservation et le modèle de la vue
- lignes 3-5 : 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. [agenda?.Lignes] vaut [null] si [agenda] est [null] (opérateur [?.]) ;
- lignes 6-15 : 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. [[]] est une liste vide ;
- lignes 19-29 : le modèle de la page. Ses classes ([AgendaModele], [AgendaVue], [LigneAgendaVue], [ReservationVue], [OptionVue]) sont définies dans [Models/ModelesVues.cs] ; [TitrePage] et [Erreur] viennent de leur classe mère commune [ModeleVue]. [TitrePage] est, comme tous les textes, une clé de traduction ;
- ligne 27 : l'adresse de l'agenda affiché, sans la fenêtre de réservation : c'est celle du bouton qui ferme la fenêtre ;
- ligne 28 : 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.
11.16.2.4. POST /agenda/reserver
- lignes 12-13 : [[Authorize(Roles = ...)]] sur l'action s'ajoute au [[Authorize]] de la classe : les trois rôles peuvent réserver (cf. exemple 26). Ici, cet attribut ne restreint rien de plus ; il 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). Ils sont reçus sous forme de chaînes, et convertis ensuite avec prudence ;
- lignes 20-23 : 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. [BadRequest()] rend une réponse 400 sans contenu, que le middleware [UseStatusCodePagesWithReExecute] transforme en page d'erreur (cf. exemple 24). [is not int idCre] est le contraire de [is int] : vrai si la valeur est [null] ; sinon, [idCre] reçoit la valeur ;
- lignes 27-29 : 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. [Patient.PatientConnecteAsync] (dossier [Web]) retrouve le client à partir du jeton ; [nameof(Role.USER)] est la chaîne ["USER"], vérifiée par le compilateur ;
- lignes 33-39 : 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. [Flash.Deposer] (dossier [Web]) range le message dans [TempData] (cf. exemple 20) ;
- lignes 42-49 : 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 ;
- lignes 57-58 : 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 63-65 : [when] ajoute une condition au [catch] : c'est un filtre d'exception [C#]. Ce bloc n'attrape la [MetierException] que si la condition est vraie : pour un patient, le message « ce patient a déjà un rendez-vous » est remplacé par un message à la deuxième personne. Sinon, l'exception passe au [catch] suivant ;
- lignes 69-71 : toute autre [MetierException] devient un message flash, avec sa clé et ses paramètres ([e.Parametres] est un dictionnaire en lecture seule : on en fait une copie, du type attendu par [Flash.Deposer]). Les autres exceptions ne sont pas attrapées : ce sont de vraies anomalies, qui aboutissent à la page d'erreur 500 ;
- ligne 74 : le schéma PRG : quel que soit le résultat, message flash et redirection vers l'agenda, qui affichera le message. Dans la version [NestJS], une fonction utilitaire regroupait cette conversion des erreurs métier ; ici, les deux blocs [catch] suffisent.
11.16.2.5. POST /agenda/annuler
- lignes 5-7 : 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 ;
- lignes 9-12 : les mêmes contrôles que pour la réservation ;
- lignes 14-18 : 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 22-25 : la suppression, puis le schéma PRG.
11.16.2.6. Les droits et les fonctions utilitaires
- lignes 4-12 : un gestionnaire a tous les droits ; un patient peut réserver s'il n'a pas de rendez-vous à venir. Les arguments nommés ([Gestionnaire: true]) rendent l'appel du constructeur du [record] lisible ;
- lignes 18-21 : la lecture des deux champs cachés communs aux formulaires POST. Les paramètres [out] sont des résultats supplémentaires de la méthode : elle rend un booléen (les champs sont-ils valides ?) et, par ses paramètres [out], les valeurs converties. C'est le procédé de [int.TryParse] ;
- lignes 24-27 : 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 fonction : les URL de la page sont écrites à un seul endroit ;
- ligne 30 : 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.
11.16.3. Controllers/PersonnesControllerBase.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | |
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 [Views/Personnes]) ;
- lignes 25-26 : MVC ne prend pour contrôleurs que les classes concrètes : seules [MedecinsController] et [ClientsController] reçoivent des requêtes ;
- lignes 38-45 : la configuration que chaque sous-classe fournit : l'URL de base, les textes (des clés de traduction, regroupées dans le [record] [ClesPersonnes] de [Models/ModelesVues.cs]), et les fonctions à appeler pour lister, trouver, ajouter, modifier, supprimer. [Func<int, Task<T?>>] est le type d'une fonction qui reçoit un [int] et rend une [Task<T?>] : on range ainsi des méthodes dans des propriétés, pour les appeler plus tard. [where T : Personne] est une contrainte du type générique : [T] sera [Medecin] ou [Client], deux classes dérivées de [Personne] ; le code peut donc utiliser [p.Prenom] ou [p.User], communs à toutes les personnes ;
- ligne 47 : la classe mère, générique (cf. la classe [PageVue<TModel>] de l'exemple 22) et abstraite. Elle reçoit [ApplicationModel] par son constructeur principal, et le transmet à ses sous-classes par la propriété protégée [Application] ([protected] : visible des seules sous-classes) ;
- ligne 51 : une propriété abstraite : chaque sous-classe doit la définir ;
- lignes 56-69 : la liste. Les attributs de route posés sur les méthodes d'une classe mère sont hérités : combinés au [[Route("medecins")]] de la sous-classe, ils donnent [GET /medecins]. Le modèle contient, pour chaque personne, les URL de ses boutons Modifier et Supprimer. [p.User?.Login ?? ""] : le login du compte associé, ou une chaîne vide si la personne n'a pas de compte ;
- ligne 60 : la vue est désignée par son chemin complet. Avec son seul nom, MVC la chercherait dans le dossier du contrôleur ([Views/Medecins] ou [Views/Clients], cf. exemple 13), alors qu'elle est commune aux deux ;
- lignes 75-76 : le formulaire vide ;
- lignes 83-98 : l'ajout, en trois temps, comme à l'exemple 28. Le paramètre [formulaire] a été rempli et validé par MVC avant l'appel (cf. exemple 19) : 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 107-121 : le formulaire de modification, prérempli. La contrainte de route [:int] (cf. exemple 02) n'accepte qu'un identifiant entier : [/medecins/abc/modifier] ne correspond à aucune route, d'où une page 404 (le [ParseIntPipe] de [NestJS] donnait une erreur 400). Une personne inexistante donne également une 404. Le numéro de version, champ caché du formulaire, servira au verrou optimiste (cf. exemple 28) ;
- lignes 128-143 : la modification, sur le même modèle que l'ajout ;
- lignes 152-165 : la suppression. La personne est lue avant, pour afficher son nom dans le message de confirmation. 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 171-188 : le formulaire, pour la création ([id] vaut [null]) comme pour la modification : seuls le titre et l'adresse d'envoi changent. [Validation.Preparer] (dossier [Web]) complète le modèle avec les erreurs par champ, le message du bas de page et les champs à encadrer en rouge ;
- lignes 193-203 : 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.ChampsErreursMetier] associe à chaque clé d'erreur ses champs : [prenom] et [nom] pour un homonyme). [is "A" or "B"] compare une valeur à plusieurs constantes ;
- lignes 207-213 : les valeurs à réafficher, jamais le mot de passe ;
- lignes 217-224 : les données transmises à la couche métier. Les [!] sont sans risque : la validation a vérifié la présence de ces champs ;
- lignes 228-230 : les paramètres [{nom}] et [{login}] des messages de succès : le prénom et le nom de la personne, et son login.
11.16.4. Controllers/MedecinsController.cs
- lignes 20-22 : réservé à l'ADMIN. L'attribut posé sur la classe s'applique à toutes ses actions, héritées comprises. Le contrôleur hérite de [PersonnesControllerBase<Medecin>] : le type [T] de la classe mère devient [Medecin]. Son constructeur principal transmet [application] à celui de la classe mère. (En [TypeScript], il fallait écrire un constructeur explicite pour que [NestJS] sache quoi injecter : rien de tel ici) ;
- lignes 24-34 : la configuration. [override] redéfinit la propriété abstraite de la classe mère ; [=>] en fait une propriété calculée, qui fabrique l'objet à chaque lecture. Les arguments nommés ([Url:], [Cles:]...) rendent la liste lisible ;
- lignes 35-39 : les fonctions à appeler, qui sont des méthodes d'[ApplicationModel]. [Application.GetAllMedecinsAsync], écrit sans parenthèses, n'appelle pas la méthode : il la désigne, et le compilateur la convertit en une valeur du type [Func] attendu.
11.16.5. Controllers/ClientsController.cs
Le même contrôleur, pour les clients : autre route, autres clés de traduction, autres méthodes d'[ApplicationModel].
11.16.6. Controllers/InscriptionController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | |
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 ;
- lignes 40-45 : le contrôleur n'a pas d'attribut [[Authorize]] : ses pages sont ouvertes à tous. Il reçoit quatre services par injection ;
- lignes 51-52 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (attribut [formaction], cf. exemple 20). Seuls les champs du captcha sont lus ;
- ligne 54 : l'adresse IP du navigateur, par une méthode statique d'[AuthController] ;
- lignes 57-61 : 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. [new() { ["minutes"] = ... }] crée le dictionnaire des paramètres du message : son type est déduit du paramètre de [Flash.Deposer] ;
- lignes 66-70 : 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 74-75 : le captcha est correct : un laissez-passer (cf. [Auth/PasseInscriptionService.cs]) est déposé dans un cookie, et le navigateur est redirigé vers le formulaire ;
- lignes 81-88 : le formulaire, seulement avec un laissez-passer valide. Taper directement l'adresse [/inscription] ne sert à rien ;
- lignes 96-97 : la création. Le formulaire [FormulaireInscription] (dossier [Models]) a été lié et validé par MVC ;
- lignes 99-101 : le laissez-passer est vérifié de nouveau : la requête POST pourrait avoir été envoyée sans passer par le formulaire ;
- lignes 104-109 : les valeurs à réafficher en cas d'erreur, sans le mot de passe. Le titre par défaut est [Mr] ;
- lignes 113-116 : la limite horaire, vérifiée de nouveau au moment de la création ;
- lignes 121-128 : les erreurs de saisie, champ par champ (cf. exemple 19). 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 136-148 : 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 152-154 : le laissez-passer a servi : il est consommé côté serveur et son cookie est effacé. Pour effacer un cookie, il faut donner le même chemin ([Path]) que lors de sa création : le cookie du laissez-passer n'est envoyé qu'aux adresses [/inscription...]. La création est enfin comptée pour la limite horaire ;
- lignes 157-158 : retour à la page de connexion, login prérempli, avec un message de succès. [Uri.EscapeDataString] encode le login pour qu'il puisse figurer dans une URL ;
- ligne 163 : le laissez-passer est lu dans le cookie de la requête ;
- lignes 166-169 : sans laissez-passer, retour à la page de connexion avec un message ;
- lignes 173-181 : le formulaire, préparé par [Validation.Preparer] comme celui des médecins et des clients. [erreurParams] sert aux paramètres du message du bas de page (le nombre de minutes).
11.16.7. Controllers/EspacePatientController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | |
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 35-39 : réservé aux patients. Le contrôleur n'a pas d'attribut [[Route]] : chaque action donne son adresse complète, car elles ne partagent pas de préfixe ([/mon-espace], [/mon-compte]) ;
- lignes 44-59 : la page « Mon espace ». [Patient.PatientConnecteAsync] retrouve le client à partir du jeton ; si son compte a été supprimé depuis la connexion, elle lève une [SessionInvalideException], que le filtre [Web/HtmlExceptionFilter.cs] transforme en retour à la page de connexion. La date est formatée par [DatesWeb.FormaterJour] selon la langue de la requête, fixée par le middleware de localisation (cf. exemple 22) ; chaque rendez-vous porte le lien vers l'agenda du médecin ce jour-là ;
- lignes 66-82 : l'annulation de son rendez-vous. [condition ? valeur1 : valeur2] donne ici le rendez-vous, ou [null] si l'identifiant est invalide. Un rendez-vous inexistant et celui d'un autre patient (ligne 73) reçoivent la même réponse : on ne révèle rien sur les rendez-vous des autres ;
- lignes 88-98 : le formulaire du compte, prérempli, avec le numéro de version (verrou optimiste) ;
- lignes 105-106 : la modification, en trois temps : validation, vérification du mot de passe actuel, couche métier ;
- lignes 109-115 : les valeurs à réafficher en cas d'erreur, jamais les mots de passe ;
- lignes 118-125 : 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 129-138 : le mot de passe actuel. [var (cle, parametres) = ...] déconstruit le couple rendu par la vérification. 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 143-150 : la modification par la couche métier, qui applique les mêmes règles que pour l'ADMIN (unicité, verrou optimiste) ;
- ligne 154 : 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 158-160 : un refus de la couche métier réaffiche le formulaire, les champs concernés encadrés en rouge ;
- lignes 169-183 : 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 180-181 : 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 190-191 : la vérification du mot de passe actuel. Elle rend [null] si le mot de passe est correct, sinon un tuple : un couple de valeurs nommées (la clé du message et ses paramètres). Le [?] final rend ce tuple « nullable » ;
- lignes 195-198 : 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 ;
- lignes 200-204 : la vérification elle-même, confiée à [AuthService] ; un échec est compté comme un échec de connexion ;
- lignes 209-217 : le formulaire du compte.
11.16.8. Controllers/ErreurController.cs
C'est le contrôleur de l'exemple 24, avec des messages traduits :
- lignes 23-28 : la clé du message de chaque code d'erreur. [static readonly] : un seul dictionnaire, créé au chargement de la classe et jamais remplacé ;
- lignes 30-31 : [[Route]], sans méthode HTTP, accepte toutes les méthodes : une requête POST ré-exécutée (un patient qui tente une annulation réservée aux gestionnaires, par exemple) reste une requête POST ;
- lignes 35-37 : un code hors des erreurs HTTP (une adresse [/erreur/200] tapée à la main) devient une erreur 500 ;
- ligne 39 : la page garde le code d'erreur d'origine ;
- lignes 40-44 : le modèle de la page. [GetValueOrDefault] rend la clé associée au code, ou [ERRORS.SERVER_ERROR] pour tout autre code (500 en particulier).
11.17. Les vues
Les vues n'ont presque rien à décider : leur modèle a été préparé par les contrôleurs, dans les classes du fichier [Models/ModelesVues.cs] (quels boutons afficher, quelles URL, quelles clés de traduction). Chaque vue déclare le type de son modèle ([@model], cf. exemple 13) : une faute de frappe dans [@Model.Xxx] est une erreur de compilation. Chaque vue commence aussi par un commentaire [Razor] [@* ... *@] qui décrit son rôle ; ces commentaires n'apparaissent pas dans les pages envoyées au navigateur.
Une différence avec les exemples du chapitre 4 : l'étude de cas n'utilise pas les Tag Helpers. Les vues partielles sont insérées par [@await Html.PartialAsync("_Nom")], l'équivalent de la balise [<partial name="_Nom">] de l'exemple 15. Un second argument, facultatif, fournit le modèle de la vue partielle ; sans lui, elle reçoit le modèle de la page. La méthode est asynchrone (la vue partielle peut avoir à être compilée ou lue sur le disque) : [await] attend son résultat.
11.17.1. Le gabarit et les vues partielles
11.17.1.1. Views/_ViewImports.cshtml
- lignes 9-11 : les espaces de noms dont les vues utilisent les types : les modèles des vues ([RdvMedecins.Models]), les messages flash ([RdvMedecins.Web]) ;
- ligne 12 : la classe mère de toutes les vues, [Web/PageVue.cs], comme dans l'exemple 22. Elle leur apporte la traduction [@T("CLE")], la langue [Langue], l'utilisateur connecté [Utilisateur], le message flash [MessageFlash], le chemin demandé [Chemin] et la méthode [Actif] (menu actif de la barre de navigation). Dans la version [NestJS], ces informations passaient par [res.locals]. Il n'y a pas de directive [@addTagHelper] : l'application n'utilise pas de Tag Helpers.
11.17.1.2. Views/_ViewStart.cshtml
- ligne 9 : toutes les pages utilisent le gabarit [Views/Shared/_Layout.cshtml] (cf. exemple 15). [Handlebars] utilisait automatiquement [views/layout.hbs] ; [Razor] demande qu'on le dise.
11.17.1.3. Views/Shared/_Layout.cshtml
- ligne 20 : le gabarit partage le modèle de la page, accessible par [ViewData.Model] (de type [object]). Tous les modèles de pages dérivent de la classe abstraite [ModeleVue], qui porte la clé du titre ([TitrePage]) et l'erreur de la page ([Erreur]). [as ModeleVue] rend [null] si le modèle n'est pas un [ModeleVue] : le gabarit fonctionne avec n'importe quelle page ;
- ligne 23 : la langue de la page (cf. exemple 22) ;
- ligne 27 : le titre, traduit, par exemple « Agenda - RdvMedecins ». Contrairement à l'exemple 22, la clé n'est pas déposée par la vue dans [ViewData] : elle est fournie par le contrôleur, dans le modèle typé. [page?.TitrePage] vaut [null] si [page] est [null], et l'opérateur [??] remplace alors la clé par [APP.TITLE] ;
- lignes 28-29 : les feuilles de style, servies par [UseStaticFiles] depuis le dossier [wwwroot] : [Bootstrap] ([wwwroot/lib/bootstrap]) et la feuille propre à l'application ;
- lignes 33-36 : la barre de navigation, le message de succès (en haut), la page demandée ([@RenderBody()]), 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.
11.17.1.4. Views/Shared/_Navbar.cshtml
- ligne 12 : l'utilisateur connecté, tel que le décrit la classe [UtilisateurVue] de [Web/PageVue.cs] (login, nom, rôle, [EstAdmin], [EstPatient]), tiré du jeton JWT. Il vaut [null] sur les pages publiques (connexion, création de compte) ;
- lignes 17-31 : les menus dépendent du rôle : « Mon espace » pour un patient, « Médecins » et « Clients » pour l'ADMIN. Ce n'est qu'un confort : la protection est assurée par les attributs [[Authorize(Roles = ...)]] des contrôleurs (un patient qui tape [/medecins] à la main obtient une page 403). [Actif("/medecins")] rend [active] si le chemin demandé commence par [/medecins] : [Bootstrap] met alors ce menu en évidence. Le préfixe [/mon-] couvre les deux pages du patient, [/mon-espace] et [/mon-compte] ;
- ligne 34 : sans menus, un bloc vide occupe la place ([me-auto] : marge droite automatique), pour que la suite reste à droite de la barre ;
- lignes 40-41 : les liens FR / EN de l'exemple 22. L'expression [@( ... )] est entre parenthèses parce qu'elle contient des espaces (cf. exemple 14) ;
- lignes 44-52 : le rôle, le nom, et le bouton de déconnexion, dans un formulaire POST (cf. exemple 21).
11.17.1.5. Views/Shared/_Flash.cshtml
- ligne 9 : le message flash déposé par l'action précédente (cf. exemple 20 et [Web/Flash.cs]). Lire un message flash l'efface ; comme la page le lit deux fois (ici et dans [_Erreurs.cshtml]), la classe [Flash] le garde, une fois lu, dans [HttpContext.Items] (cf. chapitre 6) ;
- ligne 11 : [flash is { Type: TypeFlash.Success }] est un motif de propriétés : la condition est vraie si [flash] n'est pas [null] et si sa propriété [Type] vaut [TypeFlash.Success]. Seul un message de succès s'affiche ici ;
- ligne 14 : le message est une clé ; ses paramètres éventuels ([nom], [login], [minutes]) sont un dictionnaire, passé à la seconde forme de la méthode [T]. [role="status"] : un lecteur d'écran annonce le message sans interrompre l'utilisateur.
11.17.1.6. Views/Shared/_Erreurs.cshtml
- lignes 15-18 : les deux sources d'erreur : l'erreur de la page affichée (propriété [Erreur] du modèle : formulaire refusé, mot de passe incorrect...), et un message flash d'erreur, déposé avant une redirection (créneau déjà réservé, suppression refusée...) ;
- lignes 20-22 : la zone s'affiche s'il y a l'une ou l'autre. [role="alert"] : un lecteur d'écran annonce le message dès l'affichage de la page, bien qu'il soit en bas. La classe [zone-erreur] est définie dans [wwwroot/css/styles.css] ;
- ligne 25 : l'opérateur [!] affirme au compilateur que [page] n'est pas [null] : il ne peut pas le déduire seul, alors que c'est certain puisque [erreur], lue dans [page], ne l'est pas.
11.17.1.7. Views/Shared/_Confirmation.cshtml
La fenêtre de l'exemple 21, avec des libellés traduits par le serveur, comme le reste de la page, et des attributs d'accessibilité :
- ligne 16 : une seule fenêtre pour toute la page, cachée au départ ([d-none]) ;
- lignes 18-19 : [role="alertdialog"] annonce une fenêtre qui attend une réponse ; [aria-modal="true"] indique que le reste de la page est inactif ; [aria-labelledby] et [aria-describedby] désignent le titre et le message que lit un lecteur d'écran ;
- ligne 24 : la croix de fermeture, qui vaut « Non » ([data-action="non"]). Elle n'a pas de texte : [aria-label] lui donne un nom pour les lecteurs d'écran ;
- lignes 32-33 : « Non, revenir » plutôt qu'« Annuler » : la question est parfois « Annuler ce rendez-vous ? », et un bouton « Annuler » y serait ambigu.
11.17.1.8. Views/Auth/_Captcha.cshtml
- ligne 15 : la vue partielle a son propre modèle, le captcha fabriqué par [Auth/CaptchaService.cs] (cf. exemple 27) : son identifiant et son image ;
- ligne 19 : l'image SVG est insérée telle quelle (cf. exemple 14) : elle est fabriquée par le serveur, sans rien qui vienne de l'utilisateur. [role="img"] et [aria-label] en font une image pour les lecteurs d'écran ;
- ligne 20 : « Autre image » recharge simplement la page courante : un nouveau captcha est tiré ;
- ligne 22 : l'identifiant du captcha, dans un champ caché : il dit au serveur quel captcha vérifier. La bonne réponse, elle, ne quitte jamais le serveur ;
- lignes 23-24 : la saisie. [autocomplete="off"], [autocapitalize="off"] et [spellcheck="false"] empêchent le navigateur de proposer, de mettre en majuscule ou de corriger les caractères recopiés.
11.17.1.9. Views/Shared/_ChampsPersonne.cshtml
Les champs d'identité (titre, prénom, nom), communs à trois formulaires : médecin ou client de l'ADMIN, création d'un compte, gestion de son compte par le patient.
- ligne 11 : [FormulaireVue] est la classe mère (abstraite) des modèles de ces trois formulaires. Elle porte les valeurs saisies ([Valeurs]), le message de chaque champ refusé ([Erreurs]) et les champs à encadrer en rouge ([ChampsInvalides]). La vue partielle accepte donc le modèle de chacune des trois pages ;
- lignes 14-19 : la liste des titres. [Model.Titres] donne des tuples (valeur, sélectionnée ?) ; [var (valeur, selectionne)] déconstruit chaque tuple en deux variables. [selected="@selectionne"] est un attribut booléen, écrit seulement si la valeur est vraie (cf. exemple 17). [ClasseInvalide("titre")] rend [is-invalid] si le champ est à encadrer en rouge (cf. exemple 19) ;
- lignes 20-23 : le message d'erreur du champ, s'il en a un. [ErreurDe] rend le message ou [null] ; le motif [is { } erreurTitre] est vrai si la valeur n'est pas [null], et la range alors dans la nouvelle variable [erreurTitre] ;
- lignes 26-45 : le prénom et le nom, sur le même schéma, côte à côte sur un écran assez large (grille [Bootstrap] : [col-sm-6]). Les attributs [maxlength] et [required] ne sont qu'un confort : les formulaires qui insèrent cette vue partielle ont l'attribut [novalidate], et c'est le serveur qui valide.
11.17.1.10. Views/Agenda/_Reservation.cshtml
- ligne 11 : la vue partielle reçoit le modèle de la page de l'agenda. [Model.Reservation!] : la page ne l'insère que si [Reservation] n'est pas [null], ce que l'opérateur [!] affirme au compilateur ;
- lignes 15-16 : les classes [Bootstrap] d'une fenêtre modale et de son fond grisé, rendus visibles « en dur » ([show d-block]) : c'est le serveur qui décide d'afficher la fenêtre, quand l'URL contient le paramètre [reserver]. Le [JavaScript] de [Bootstrap] n'est pas utilisé (il n'est même pas chargé) ;
- ligne 18 : le contenu de la fenêtre est lui-même le formulaire de réservation ;
- ligne 21 : fermer la fenêtre, c'est suivre un lien vers le même agenda, sans le paramètre [reserver] ([UrlRetour], préparée par le contrôleur) ;
- ligne 25 : « Créneau de 8:00 - 8:20 » : un paramètre de traduction ;
- lignes 28-30 : les champs cachés : ce que le serveur doit savoir en plus du patient choisi ;
- lignes 32-48 : un patient voit son nom ; un gestionnaire (ADMIN, DOCTOR) choisit le patient dans une liste. La première option a une valeur vide : avec [required], le navigateur exige qu'on choisisse un vrai patient. [autofocus] place le curseur dans la liste ;
- lignes 52-53 : « Annuler » est un lien, comme la croix ; « Confirmer » envoie le formulaire.
11.17.2. Les pages
11.17.2.1. Views/Auth/Login.cshtml
- ligne 13 : après un échec, le contrôleur renvoie cette page avec le login déjà saisi ;
- ligne 20 : 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 : [autofocus] est un attribut booléen : 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 ; le second argument fournit le modèle de la vue partielle ;
- lignes 45-46 : le second bouton envoie le même formulaire à [/inscription/acces] (attribut [formaction], cf. exemple 20) : 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 51-56 : les comptes de démonstration (mot de passe = login).
11.17.2.2. Views/Agenda/Agenda.cshtml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
Commentons ce code :
- lignes 14-34 : 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é. Un champ [type="date"] envoie la date au format [aaaa-mm-jj] (cf. exemple 18) ;
- lignes 37-42 : le message d'un patient qui a déjà un rendez-vous à venir : il explique l'absence de boutons « Réserver » ;
- ligne 45 : l'agenda, s'il a été demandé. Le motif [is { } agenda] teste que la propriété n'est pas [null] et range sa valeur dans la variable [agenda] ;
- ligne 48 : le titre : « Agenda du 2026-09-25 - Mme Marie PELISSIER » ;
- ligne 63 : un créneau réservable change de couleur au survol (classe [creneau-libre], cf. [styles.css]). Quand la valeur d'un attribut est [null], [Razor] n'écrit pas l'attribut du tout : les autres lignes n'ont pas d'attribut [class] ;
- lignes 69-80 : trois cas : libre ; occupé, avec le nom du patient (en bleu si c'est le rendez-vous du patient connecté, [Moi]) ; occupé sans nom visible, « Réservé ». Le contrôleur ne renseigne [Patient] que si l'utilisateur a le droit de voir ce nom ;
- lignes 85-90 : « 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 91-101 : « Annuler » modifie des données : c'est un formulaire POST, avec confirmation ([data-confirmation], cf. exemple 21). Les champs cachés [idMedecin] et [jour] permettent au contrôleur de rediriger vers le même agenda. Dans la boucle, [Model] désigne toujours le modèle de la page : pas besoin de l'équivalent du [@root] de [Handlebars] ;
- ligne 110 et ligne 116 : un médecin sans créneaux, ou aucun agenda encore demandé ;
- lignes 120-123 : la fenêtre de réservation, si le contrôleur l'a préparée.
11.17.2.3. Views/Personnes/Liste.cshtml
Une seule vue pour les médecins et les clients :
- lignes 8-9 : les textes propres à chacun arrivent sous forme de clés ([Model.Cles], de type [ClesPersonnes]), fournies par [MedecinsController] ou [ClientsController] : [MEDECINS.TITLE] ou [CLIENTS.TITLE], etc. ;
- lignes 26-45 : une ligne par personne, avec ses deux boutons ;
- ligne 35 : « Modifier » est un lien vers le formulaire ;
- lignes 38-41 : « Supprimer » est un formulaire POST, avec confirmation ;
- ligne 51 : « Aucun médecin. » ou « Aucun client. ».
11.17.2.4. Views/Personnes/Formulaire.cshtml
Commentons ce code :
- ligne 17 : le titre (« Ajouter un médecin », « Modifier un client »...) est la clé du titre de la page, fournie par le contrôleur ;
- ligne 22 : l'adresse d'envoi est fournie par le contrôleur : la même vue sert à l'ajout et à la modification, des médecins comme des clients. [novalidate] : c'est le serveur qui valide (cf. exemple 19) ;
- lignes 23-25 : pour une modification, la version de la fiche, dans un champ caché : c'est le verrou optimiste de l'exemple 28 ;
- ligne 29 : les champs d'identité ;
- lignes 34-44 : le login, avec un message d'erreur ou, à défaut, un texte d'aide. L'aide diffère selon qu'il s'agit d'un ajout (« Facultatif : par défaut, le nom en minuscules. ») ou d'une modification ;
- lignes 48-59 : le mot de passe, jamais prérempli : il ne repart jamais vers le navigateur. [autocomplete="new-password"] : le navigateur ne propose pas le mot de passe enregistré de l'administrateur. La longueur est limitée à 72 caractères, parce que l'algorithme [bcrypt] ne tient pas compte des caractères suivants ;
- ligne 64 : le retour à la liste est un lien.
11.17.2.5. Views/Inscription/Inscription.cshtml
La création d'un compte client, page publique. Pas de captcha ici : on n'arrive sur cette page qu'après avoir recopié celui de la page de connexion.
- ligne 17 : les mêmes champs d'identité que le formulaire de l'ADMIN ;
- lignes 22-32 : le login, cette fois obligatoire ;
- lignes 37-47 : le texte d'aide et le message « trop court » ont un paramètre, [{min}], la longueur minimale du mot de passe, fournie par le modèle ([MotDePasseMin]). Un message sans paramètre [{min}] ignore simplement la valeur fournie ;
- lignes 50-56 : la confirmation du mot de passe ;
- ligne 62 : le retour à la page de connexion.
11.17.2.6. Views/EspacePatient/Espace.cshtml
- ligne 8 : « Bonjour Mr Jean MARTIN » ;
- lignes 12-21 : les deux options sont des liens, présentés comme des cartes (classe [carte-option], cf. [styles.css]) ;
- lignes 31-46 : les rendez-vous à venir (normalement un seul). À l'intérieur d'un bloc [@if], on est en [C#] : la boucle [for] s'écrit sans [@]. Une bordure ([border-bottom]) sépare les rendez-vous, sauf après le dernier ;
- lignes 40-44 : l'annulation, un formulaire POST avec confirmation.
11.17.2.7. Views/EspacePatient/Compte.cshtml
- lignes 16-76 : la modification du compte : identité, login, nouveau mot de passe (facultatif) et sa confirmation ;
- ligne 17 : la version de la fiche du client (verrou optimiste) ;
- lignes 59-69 : le mot de passe actuel, exigé pour toute modification ;
- lignes 83-91 : 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. Views/Shared/Erreur.cshtml
- lignes 9-10 : la page d'erreur fabriquée par [ErreurController] (400, 403, 404, 500) : le code HTTP, et le message traduit. Rangée dans [Views/Shared], elle est trouvée quel que soit le contrôleur qui la demande.
11.18. Les dictionnaires
Les dictionnaires sont ceux de la version [NestJS], sans changement : ils sont lus par le service [Traducteur] (cf. exemple 22).
11.18.1. i18n/fr.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | |
- les sections regroupent les textes par page ([LOGIN], [AGENDA], [ESPACE]...) ou par thème ([ERRORS], [CONFIRMATION]). Certains noms ([DOCTOR_DAY_PICKER], [BOOKING_DIALOG]) sont ceux des composants du client [Angular] dont la version [NestJS] a repris les dictionnaires ;
- ligne 80 : un texte avec deux paramètres, [{nom}] et [{login}], remplacés au moment de la traduction ;
- lignes 98-136 : les messages d'erreur. Ce sont les clés qu'utilisent les [MetierException] de la couche métier, les attributs de validation des formulaires ([ErrorMessage = "ERRORS.FIRSTNAME_REQUIRED"]), les contrôleurs et le filtre d'exceptions. Trois d'entre eux ont un paramètre : [{minutes}] et [{min}].
11.18.2. i18n/en.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | |
Le dictionnaire anglais a exactement les mêmes clés.
11.19. Les fichiers statiques
Le dossier [wwwroot] est servi par [UseStaticFiles] (cf. exemple 15). Le dossier [wwwroot/lib/bootstrap] contient [Bootstrap] (sa feuille de style [css/bootstrap.min.css] et sa licence), qu'on ne montre pas.
11.19.1. wwwroot/css/styles.css
- 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. wwwroot/js/app.js
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.
- 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 : 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.