Skip to content

9. Dialoguer avec le serveur

Le serveur JSON [RdvMedecins] doit maintenant être lancé (port 8080), comme expliqué au chapitre précédent, de préférence en mode test du captcha ([CAPTCHA_TEST=test]), qui permet de répondre « test » à n'importe quelle image.

Les exemples 22 à 25 partagent quelques fichiers, rangés dans le dossier [src/app/commun/] du projet : l'adresse du serveur ([config.ts]), les types des réponses ([types.ts]), la classe des erreurs ([erreur-api.ts]), le service des requêtes ([api.ts]) et une barre de connexion ([barre-connexion.ts]). Ils sont présentés au fil des exemples, là où ils deviennent utiles.

9.1. Exemple 22 : une première requête HTTP

9.1.1. Où est le serveur ?

Pour un client web servi par le serveur lui-même, la question ne se pose pas : les requêtes partent vers la même origine que la page, avec une adresse relative ([/api/captcha]). Une application installée sur un téléphone n'a pas d'origine utilisable : sa page est servie par [Capacitor] lui-même, depuis https://localhost. Il faut lui donner l'adresse complète du serveur, et cette adresse dépend de l'endroit où tourne l'application :

// =========================================================================
// src/app/commun/config.ts - OÙ est le serveur JSON ?
// -------------------------------------------------------------------------
// La question ne se pose pas pour une application web servie par le
// serveur lui-même : les requêtes partent vers la MÊME origine que la page
// (une adresse relative, /api/...). Mais une application MOBILE n'a pas
// d'origine utilisable (sur Android, la page est servie par Capacitor
// depuis https://localhost) : il faut lui donner l'adresse complète du serveur.
//   - en développement web (ng serve), le serveur de développement
//     d'Angular transmet les requêtes /api au serveur JSON (fichier
//     proxy.conf.json) : une adresse relative suffit ;
//   - l'émulateur Android voit l'ordinateur qui le fait tourner à
//     l'adresse 10.0.2.2 (localhost, pour lui, c'est l'émulateur lui-même) ;
//   - un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur, doit
//     utiliser l'adresse de l'ordinateur sur ce réseau (192.168.1.20...) :
//     on la donne à la construction de l'application :
//         ng build --define "SERVEUR='http://192.168.1.20:8080'"
//     ou bien on modifie la constante serveurAndroid ci-dessous.
// =========================================================================

import { Capacitor } from '@capacitor/core';

// l'adresse du serveur pour l'application Android, quand rien d'autre
// n'est imposé : celle de l'ordinateur, vue de l'émulateur
const serveurAndroid = 'http://10.0.2.2:8080';

// la valeur donnée par --define "SERVEUR='...'" à la construction
// (ng build, ng serve). L'option remplace, dans le code compilé, le mot
// SERVEUR par la valeur donnée. Sans elle, SERVEUR n'existe pas : typeof
// permet de le vérifier sans provoquer d'erreur
declare const SERVEUR: string | undefined;
const serveurImpose = typeof SERVEUR === 'string' ? SERVEUR : '';

export const config = {
  // Capacitor.isNativePlatform() : vrai dans l'application Android (ou iOS),
  // faux dans un navigateur ; Capacitor.getPlatform() : 'android', 'ios' ou 'web'
  urlServeur:
    serveurImpose !== ''
      ? serveurImpose
      : !Capacitor.isNativePlatform()
        ? '' // le web : même origine, adresses relatives
        : Capacitor.getPlatform() === 'android'
          ? serveurAndroid
          : 'http://localhost:8080',
};
  • ligne 21 : [Capacitor], l'objet de [@capacitor/core] qui renseigne sur la plate-forme où tourne l'application. Il est utilisable partout, y compris dans un navigateur ;
  • ligne 25 : l'adresse par défaut sur [Android]. L'émulateur est une machine à part entière, avec son propre réseau : pour lui, [localhost], c'est l'émulateur lui-même. L'adresse spéciale [10.0.2.2] désigne l'ordinateur qui le fait tourner ;
  • lignes 31-32 : une adresse imposée à la construction de l'application, par l'option [--define] d'[Angular] : [ng build --define "SERVEUR='http://192.168.1.20:8080'"]. La compilation remplace, dans le code, l'identifiant [SERVEUR] par la valeur donnée (une chaîne, d'où les apostrophes). Sans l'option, [SERVEUR] n'existe pas : [declare const] annonce au compilateur [TypeScript] un identifiant qu'il ne voit pas déclaré, et [typeof SERVEUR] permet de tester son existence sans provoquer d'erreur. C'est le pendant exact de [--dart-define] et [String.fromEnvironment] en [Flutter] ;
  • lignes 34-45 : sinon, une adresse selon la plate-forme. [Capacitor.isNativePlatform()] est vrai dans l'application [Android] (ou [iOS]), faux dans un navigateur ; [Capacitor.getPlatform()] rend 'android', 'ios' ou 'web' :
  • dans le navigateur (ligne 41) : une adresse relative (''), donc la même origine que l'application ;
  • sur [Android] (ligne 43) : l'adresse de la ligne 25 ;
  • ailleurs (ligne 44, [iOS], que ce cours n'aborde pas) : [localhost].

Un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur du serveur, doit utiliser l'adresse de l'ordinateur sur ce réseau (la commande [ipconfig] de [Windows] la donne) : on la passe par [--define] à la construction, ou l'on modifie la constante de la ligne 25. Il faut aussi que le pare-feu de l'ordinateur laisse passer les connexions entrantes sur le port 8080.

9.1.2. Les réglages du projet Android

Le client [Flutter] modifiait deux lignes du fichier [android/app/src/main/AndroidManifest.xml] : la permission d'utiliser le réseau, et l'autorisation de communiquer en HTTP non chiffré (« en clair »). Avec [Capacitor], on ne touche pas à ce fichier :

  • la permission [android.permission.INTERNET] est déjà déclarée dans le projet [Android] que crée [npx cap add android] : une application [Capacitor] est une page web, elle a toujours besoin du réseau ;
  • l'autorisation du HTTP en clair est un réglage du fichier [capacitor.config.ts], à la racine du projet :
// =========================================================================
// capacitor.config.ts - la configuration de Capacitor
// -------------------------------------------------------------------------
// Capacitor transforme l'application web (le dossier produit par ng build)
// en application Android : il l'emballe dans un projet Android Studio
// (dossier android/), où elle est affichée par une "WebView", le moteur du
// navigateur Chrome du téléphone. Commandes (cf. exemple 01, « Exécution ») :
//     npx cap add android       (une seule fois : crée le dossier android/)
//     ng build                  (fabrique l'application web)
//     npx cap sync android      (la copie dans le projet Android)
//     npx cap run android       (la lance sur un émulateur ou un téléphone)
// =========================================================================

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  // l'identifiant unique de l'application sur le téléphone (et sur le Play Store)
  appId: 'fr.stahe.exemples',
  // le nom affiché sous l'icône
  appName: 'Exemples Ionic',
  // le dossier de l'application web compilée (ng build)
  webDir: 'dist/exemples_ionic/browser',
  server: {
    // autorise les requêtes HTTP non chiffrées (http://10.0.2.2:8080) vers
    // le serveur JSON de développement (exemples 22 à 25)
    cleartext: true,
  },
  plugins: {
    // CapacitorHttp : les requêtes HTTP de l'application ne sont plus faites
    // par la WebView mais par le code natif d'Android. Conséquences (cf.
    // exemples 22 et 23) : pas de contrôle CORS (le serveur JSON n'a rien à
    // autoriser), et les cookies (celui du jeton d'authentification) sont
    // rangés par Android et renvoyés automatiquement
    CapacitorHttp: {
      enabled: true,
    },
  },
};

export default config;
  • lignes 23-27 : [server.cleartext: true]. Depuis la version 9 d'[Android], une application n'a le droit de communiquer qu'en HTTPS. Notre serveur de développement parle HTTP : on l'autorise. La commande [npx cap sync android] reporte ce réglage dans le projet [Android] (l'attribut [android:usesCleartextTraffic="true"], ajouté au manifeste que [Capacitor] fusionne avec celui de l'application). En production, le serveur serait en HTTPS et ce réglage disparaîtrait ;
  • lignes 28-37 : le greffon [CapacitorHttp]. Les requêtes HTTP de l'application (celles du client HTTP d'[Angular], comme celles de [fetch]) ne sont plus envoyées par la WebView mais par le code natif d'[Android]. Pour un navigateur, l'application (https://localhost) et le serveur (http://10.0.2.2:8080) auraient deux origines différentes : il exigerait du serveur une autorisation CORS qu'il ne donne pas, et refuserait de joindre le cookie du jeton. Le code natif n'a pas ces règles. Le code [TypeScript] de l'application, lui, ne change pas : c'est [Capacitor] qui détourne les requêtes.

9.1.3. Et dans le navigateur ?

Pendant le développement, l'application tourne dans le navigateur de l'ordinateur : [ng serve] la sert sur le port 8100, le serveur JSON est sur le port 8080. Deux origines, là encore, et [CapacitorHttp] n'agit pas dans un navigateur. La solution est un proxy, déclaré dans [angular.json] (option ["proxyConfig": "proxy.conf.json"] de la cible [serve]) :

1
2
3
4
5
6
7
8
{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false,
    "changeOrigin": false,
    "logLevel": "warn"
  }
}
  • ligne 2 : toute requête dont l'adresse commence par [/api]...
  • ligne 3 : ... est transmise par le serveur de développement d'[Angular] au serveur JSON, et sa réponse renvoyée au navigateur. Pour le navigateur, l'application et l'API ont la même origine (http://localhost:8100) : pas de CORS, et le cookie circule. C'est exactement le rôle du proxy de [Vite] dans le cours [React], et du fichier [web_dev_config.yaml] dans le cours [Flutter] ;
  • ligne 4 : [secure: false] : ne pas exiger un certificat HTTPS valide de la cible (elle est en HTTP de toute façon) ; ligne 6 : n'afficher que les avertissements du proxy.

9.1.4. La requête

On demande un captcha au serveur : [GET /api/captcha], qui répond [{ "id": "...", "svg": "<svg ...>" }].

// =========================================================================
// src/app/ex22-http/ex22.page.ts - une première requête HTTP vers le serveur
// -------------------------------------------------------------------------
// Le serveur JSON RdvMedecins doit être lancé (port 8080). On lui demande
// un captcha : GET /api/captcha -> { "id": "...", "svg": "<svg ...>" }.
//
//   - HttpClient : le client HTTP d'Angular (fourni à toute l'application
//     par provideHttpClient(), dans main.ts). http.get<T>(adresse) renvoie
//     un Observable : la requête part quand on s'y ABONNE (subscribe), et
//     la réponse arrive plus tard, son corps JSON déjà décodé ;
//   - une réponse d'erreur (4xx, 5xx) ou l'absence de réponse arrivent
//     dans la fonction error de l'abonnement ;
//   - l'adresse du serveur dépend de l'endroit où tourne l'application
//     (navigateur, émulateur Android, téléphone) : cf. commun/config.ts ;
//   - trois états à afficher : en cours, réussi, en erreur ;
//   - l'image du captcha est du SVG : une balise <img> sait l'afficher.
// =========================================================================

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import {
  IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonContent, IonHeader, IonNote, IonProgressBar,
  IonTitle, IonToolbar,
} from '@ionic/angular';
import { Subscription } from 'rxjs';
import { config } from '../commun/config';
import { Captcha, imageCaptcha } from '../commun/types';

@Component({
  selector: 'app-ex22',
  imports: [
    IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonProgressBar, IonCard, IonCardContent,
    IonButton, IonNote,
  ],
  templateUrl: './ex22.page.html',
  styles: `
    .erreur { --background: #ffebee; --color: #333; margin: 12px 0; }
    .image { border: 1px solid #ddd; border-radius: 6px; padding: 4px; text-align: center; }
    .image img { height: 60px; }
    .id { font-family: monospace; font-size: 11px; margin: 8px 0; }
  `,
})
export class Ex22Page {
  private readonly http = inject(HttpClient);
  readonly serveur = config.urlServeur || '(même origine : /api, proxy de ng serve)';
  readonly imageCaptcha = imageCaptcha;

  // l'état de l'écran : des signaux
  readonly captcha = signal<Captcha | null>(null);
  readonly enCours = signal(true);
  readonly erreur = signal<string | null>(null);
  readonly nbRequetes = signal(0);

  // l'abonnement à la requête en cours (s'il y en a une)
  private requete?: Subscription;

  constructor() {
    this.charger(); // la première requête, dès la création de la page
    // la page détruite (bouton Retour) : la requête en cours est abandonnée
    inject(DestroyRef).onDestroy(() => this.requete?.unsubscribe());
  }

  charger(): void {
    // se désabonner d'une requête encore en cours l'ANNULE : sa réponse,
    // devenue inutile, ne sera jamais traitée (pas de réponse "périmée")
    this.requete?.unsubscribe();
    this.enCours.set(true);
    this.erreur.set(null);
    // <Captcha> : le type du corps JSON attendu (une promesse faite au
    // compilateur : rien n'est vérifié à l'exécution)
    this.requete = this.http.get<Captcha>(`${config.urlServeur}/api/captcha`).subscribe({
      // next : la réponse est arrivée (code 2xx), son corps JSON est décodé
      next: (captcha) => {
        this.captcha.set(captcha);
        this.nbRequetes.update((n) => n + 1);
        this.enCours.set(false);
      },
      // error : une réponse d'erreur (4xx, 5xx), ou pas de réponse du tout
      // (serveur arrêté, mauvaise adresse : status vaut alors 0)
      error: (e: HttpErrorResponse) => {
        this.erreur.set(e.status === 0 ? 'pas de réponse (status 0)' : `le serveur a répondu ${e.status}`);
        this.captcha.set(null);
        this.enCours.set(false);
      },
    });
  }
}
<!-- ======================================================================
     ex22.page.html - un captcha demandé au serveur
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Exemple 22 - une première requête HTTP</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-note>Serveur : {{ serveur }}</ion-note>

  <!-- une barre de progression "indéterminée" pendant la requête -->
  @if (enCours()) {
    <ion-progress-bar type="indeterminate" class="ion-margin-top" />
  }

  @if (erreur(); as erreur) {
    <ion-card class="erreur">
      <ion-card-content>
        Impossible d'obtenir un captcha : {{ erreur }}.<br />
        Le serveur JSON RdvMedecins est-il lancé (port 8080) ?
      </ion-card-content>
    </ion-card>
  }

  @if (captcha(); as captcha) {
    @if (!enCours()) {
      <!-- l'image SVG, sous la forme d'une adresse data: (cf. commun/types.ts) -->
      <div class="image ion-margin-top">
        <img [src]="imageCaptcha(captcha)" alt="captcha" />
      </div>
      <div class="id">id : {{ captcha.id }}</div>
    }
  }

  <!-- désactivé pendant une requête -->
  <ion-button class="ion-margin-top" shape="round" [disabled]="enCours()" (click)="charger()">Autre image</ion-button>
  <p><ion-note>{{ nbRequetes() }} requête(s) réussie(s)</ion-note></p>
</ion-content>
  • ligne 19 (.ts) : [HttpClient], le client HTTP d'[Angular]. Il est fourni à toute l'application par [provideHttpClient()], dans [main.ts], et la page l'obtient par [inject(HttpClient)] (ligne 44) ;
  • ligne 45 (.ts) : l'adresse du serveur, affichée en haut de l'écran : une adresse vide signifie « même origine » ;
  • lignes 49-52 (.ts) : l'état de l'écran, dans des signaux : le captcha reçu (ou [null]), les trois états d'une requête (en cours, en erreur, réussie), le nombre de requêtes réussies ;
  • ligne 55 (.ts) : l'abonnement ([Subscription]) à la requête en cours ;
  • lignes 57-61 (.ts) : la première requête part dès la création de la page. [DestroyRef] prévient quand la page est détruite (bouton Retour) : on se désabonne alors de la requête en cours ;
  • lignes 63-86 (.ts) : la requête :
  • ligne 71 (.ts) : [this.http.get<Captcha>(adresse)] ne fait pas encore partir la requête : il rend un [Observable] (exemple 19) de la réponse. La requête part quand on s'y abonne ([subscribe]). Le type [<Captcha>] est celui du corps JSON attendu : une promesse faite au compilateur, que rien ne vérifie à l'exécution ;
  • lignes 73-77 (.ts) : [next] reçoit la réponse, si son code est 2xx. Son corps JSON est déjà décodé : c'est un objet [JavaScript] (le paquet [http] de [Flutter] rendait du texte, qu'il fallait passer à [jsonDecode]) ;
  • lignes 80-84 (.ts) : [error] reçoit une [HttpErrorResponse], pour une réponse d'erreur (4xx, 5xx) comme pour l'absence de réponse (serveur arrêté, mauvaise adresse, réseau coupé). On les distingue par [e.status], qui vaut 0 quand le serveur n'a pas répondu. Une différence avec [http.get] de [Flutter], qui considérait une réponse 4xx ou 5xx comme une réponse ordinaire, dont il fallait tester le code ;
  • ligne 66 (.ts) : la requête précédente, si elle n'est pas terminée, est abandonnée : se désabonner d'une requête HTTP l'annule (le navigateur l'interrompt), et sa réponse ne sera jamais traitée. C'est ici que l'[Observable] montre son avantage sur la [Promise], qu'on ne peut pas annuler : l'exemple [Flutter] devait numéroter ses demandes pour ignorer les réponses périmées ;
  • lignes 18-20 (html) : une barre de progression ([<ion-progress-bar type="indeterminate">]) pendant la requête ;
  • lignes 22-29 (html) : l'erreur, dans une carte ;
  • lignes 31-39 (html) : le captcha. [@if (captcha(); as captcha)] donne un nom à la valeur du signal, quand elle n'est pas [null] ;
  • ligne 35 (html) : l'image du captcha est du SVG, sous forme de texte. Une balise [<img>] sait l'afficher, à condition de le lui donner sous la forme d'une adresse ; c'est le rôle de la fonction [imageCaptcha] de [commun/types.ts] :
// le texte SVG -> une adresse "data:" utilisable par <img [src]> : l'image
// est DANS l'adresse. Une image SVG affichée par <img> ne peut exécuter
// aucun script : pas de risque, même si son contenu était douteux
export function imageCaptcha(c: Captcha): string {
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.svg)}`;
}
  • ligne 32 : une adresse [data:] contient le fichier lui-même : son type ([image/svg+xml]), puis son contenu, encodé par [encodeURIComponent] pour pouvoir figurer dans une adresse. Pourquoi pas insérer directement le texte SVG dans la page, par [[innerHTML]] ? [Angular] le refuserait : un SVG peut contenir des scripts, et [Angular] « nettoie » tout HTML inséré ainsi, en retirant les balises qu'il ne connaît pas comme sûres, dont [<svg>]. On pourrait le forcer ([DomSanitizer.bypassSecurityTrustHtml]), mais l'image est plus simple, et sans risque : une image affichée par [<img>] n'exécute jamais de script. En [Flutter], c'était le paquet [flutter_svg] qui dessinait le SVG ;
  • ligne 42 (html) : « Autre image », désactivé pendant une requête.

Dans le navigateur, avec le proxy de [ng serve] ; puis le serveur ne répondant pas (pour cette copie d'écran, le script de capture fait échouer la requête, comme le ferait un serveur arrêté) :

Les outils de développement de [Chrome] (F12), onglet « Réseau » (« Network »), montrent chaque requête HTTP de l'application, ses en-têtes et son corps : ici, [GET http://localhost:8100/api/captcha], transmise par le proxy. Sur le téléphone, on inspecte l'application depuis l'ordinateur, à l'adresse [chrome://inspect] (cf. chapitre « Déploiement ») ; les requêtes passant par [CapacitorHttp], c'est-à-dire par le code natif, y sont moins visibles.

Le client HTTP d'[Angular] offre aussi, depuis la version 19, [httpResource()] : le pendant, pour une requête GET, de la [resource()] de l'exemple 19. On lui donne l'adresse, et il rend un objet à signaux ([isLoading()], [value()], [error()], [reload()]) : toute la gestion des trois états de cet exemple tiendrait en une ligne. On a préféré, pour une première requête, écrire ces états soi-même ; l'étude de cas utilisera [resource()].

Le serveur dépose le jeton JWT dans un cookie. Le client [Flutter] devait, à cet endroit, écrire un client HTTP qui lit l'en-tête [Set-Cookie] de chaque réponse, range les cookies (et les mémorise sur l'appareil), et les renvoie dans l'en-tête [Cookie] de chaque requête : une application [Flutter] n'a pas de navigateur pour le faire à sa place.

Une application [Ionic], elle, n'a rien à écrire :

  • dans le navigateur de l'ordinateur, c'est le navigateur qui range le cookie et le renvoie, comme pour n'importe quel site. Le proxy de [ng serve] donne la même origine à l'application et à l'API : le cookie [SameSite=Strict] est accepté et renvoyé. Le code de l'application ne peut d'ailleurs ni lire ni écrire ce cookie ([HttpOnly]) ;
  • sur [Android], le greffon [CapacitorHttp] (exemple 22) fait passer les requêtes par le code natif. [Capacitor] installe, au démarrage de l'application, le gestionnaire de cookies d'[Android] (celui de la WebView) comme gestionnaire des requêtes natives : les cookies reçus y sont rangés, et renvoyés aux requêtes suivantes vers le même serveur. Ils sont conservés sur l'appareil (le nôtre a une durée de vie, [Max-Age=7200] ; seuls les cookies « de session », sans durée, sont effacés au démarrage de l'application) : on reste connecté quand on arrête puis relance l'application.

Le jeton n'est donc jamais manipulé par le code de l'application, ni rangé par elle en clair sur l'appareil (le client [Flutter] le rangeait dans les [shared_preferences]).

9.2.2. Une commande

// =========================================================================
// src/app/ex23-connexion/commande.ts - une ACTION, son état, son résultat
// -------------------------------------------------------------------------
// Une action asynchrone (se connecter, enregistrer...) a un état que
// l'écran doit montrer : est-elle EN COURS (bouton désactivé) ? s'est-elle
// terminée par une ERREUR (message) ? C'est le modèle "Command" des guides
// d'architecture (celui de Flutter, par exemple), réduit à l'essentiel :
//
//     readonly connexion = new Commande();
//     this.connexion.executer(() => this.seConnecter());   // lance l'action
//     connexion.enCours(), connexion.erreur()               // son état
//
// Son état est fait de SIGNAUX : le gabarit qui les lit est mis à jour
// quand la commande démarre et quand elle se termine.
// =========================================================================

import { signal } from '@angular/core';

export class Commande {
  private readonly _enCours = signal(false);
  private readonly _erreur = signal<unknown>(null);
  readonly enCours = this._enCours.asReadonly();
  readonly erreur = this._erreur.asReadonly();

  // exécute l'action, si une autre n'est pas déjà en cours
  async executer(action: () => Promise<void>): Promise<void> {
    if (this._enCours()) return; // pas de double envoi (double appui sur le bouton)
    this._enCours.set(true);
    this._erreur.set(null);
    try {
      await action();
    } catch (e) {
      this._erreur.set(e); // l'erreur est GARDÉE, pour être affichée
    } finally {
      // finally : exécuté dans TOUS les cas, succès ou erreur
      this._enCours.set(false);
    }
  }

  effacerErreur(): void {
    this._erreur.set(null);
  }
}

Une action asynchrone (se connecter, enregistrer...) a un état que l'écran doit montrer : est-elle en cours (bouton désactivé) ? s'est-elle terminée par une erreur (message) ? C'est le modèle « Command » des guides d'architecture (celui de [Flutter] l'appelle ainsi), réduit à l'essentiel. Le cours [React] utilisait pour cela les « actions » de [React] 19 ([useActionState]).

  • lignes 19-23 : une simple classe (chaque écran crée la sienne avec [new]), dont l'état est fait de deux signaux privés, exposés en lecture seule. Le gabarit qui lit [enCours()] ou [erreur()] est mis à jour quand la commande démarre et quand elle se termine : c'est le rôle que jouait [ChangeNotifier] et [ListenableBuilder] en [Flutter] ;
  • lignes 26-38 : exécuter une action : si une autre est en cours, on ne fait rien (ligne 27 : pas de double envoi, si l'on appuie deux fois sur le bouton) ; sinon, la commande passe « en cours » ; en cas d'exception, l'erreur est gardée, pour être affichée (ligne 33) ; dans tous les cas ([finally], lignes 34-37), la commande n'est plus en cours. L'erreur est de type [unknown] : en [JavaScript], on peut lancer n'importe quelle valeur, et [TypeScript] ne sait donc rien de ce qu'attrape un [catch].

9.2.3. L'écran

// =========================================================================
// src/app/ex23-connexion/ex23.page.ts - se connecter au serveur RdvMedecins
// -------------------------------------------------------------------------
//   1. au démarrage : GET /api/auth/moi - sommes-nous déjà connectés ?
//      (l'application a pu être arrêtée ; le serveur, lui, lit le cookie
//      qu'on lui présente) ;
//   2. connexion : POST /api/auth/login, corps JSON { login, password,
//      captchaId, captcha }. En cas de succès, le serveur envoie le cookie
//      du jeton JWT (entête Set-Cookie) ;
//   3. requête protégée : GET /api/medecins. Le cookie l'accompagne ;
//   4. déconnexion : POST /api/auth/logout - le serveur efface le cookie.
// Et le cookie ? Le code n'a RIEN à faire : dans un navigateur, c'est le
// navigateur qui le range et le renvoie ; sur Android, c'est le code natif
// (greffon CapacitorHttp, cf. capacitor.config.ts).
// =========================================================================

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonContent, IonHeader, IonInput, IonNote, IonText,
  IonTitle, IonToolbar,
} from '@ionic/angular';
import { firstValueFrom } from 'rxjs';
import { config } from '../commun/config';
import { Captcha, imageCaptcha } from '../commun/types';
import { Commande } from './commande';

// les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera des dictionnaires)
const messages: Record<string, string> = {
  'LOGIN.ERROR': 'Login ou mot de passe incorrect.',
  'ERRORS.WRONG_CAPTCHA': "Les caractères recopiés ne correspondent pas à l'image.",
  'ERRORS.TOO_MANY_ATTEMPTS': 'Trop de tentatives infructueuses. Réessayez plus tard.',
};

// ce que renvoient POST /api/auth/login et GET /api/auth/moi, et une ligne de GET /api/medecins
interface Utilisateur {
  login: string;
  nom: string;
  role: string;
}
interface Medecin {
  id: number;
  titre: string;
  prenom: string;
  nom: string;
}

@Component({
  selector: 'app-ex23',
  imports: [
    FormsModule, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonCard, IonCardContent,
    IonInput, IonButton, IonText, IonNote,
  ],
  templateUrl: './ex23.page.html',
  styles: `
    .captcha { display: flex; align-items: center; margin-top: 12px; }
    .captcha img { height: 50px; }
    .erreur { --background: #ffebee; --color: #333; }
    h3 { font-size: 14px; font-weight: bold; margin: 12px 0 4px; }
  `,
})
export class Ex23Page {
  private readonly http = inject(HttpClient);
  readonly imageCaptcha = imageCaptcha;

  readonly utilisateur = signal<Utilisateur | null>(null); // null : personne n'est connecté
  readonly medecins = signal<Medecin[]>([]);
  readonly captcha = signal<Captcha | null>(null);
  readonly pret = signal(false); // la vérification de départ est-elle terminée ?
  readonly erreurServeur = signal<string | null>(null);
  readonly connexion = new Commande();
  // les champs du formulaire, reliés par [(ngModel)]
  readonly login = signal('admin');
  readonly password = signal('');
  readonly reponseCaptcha = signal('');

  constructor() {
    this.verifierSession();
  }

  private url(chemin: string): string {
    return `${config.urlServeur}/api${chemin}`;
  }

  // une requête GET dont on veut le corps JSON. Une réponse qui n'est pas un
  // 2xx (ou l'absence de réponse) rejette la Promise avec une HttpErrorResponse
  private lire<T>(chemin: string): Promise<T> {
    return firstValueFrom(this.http.get<T>(this.url(chemin)));
  }

  // un POST : l'objet corps est envoyé en JSON (entête content-type: application/json)
  private poster<T>(chemin: string, corps: object): Promise<T> {
    return firstValueFrom(this.http.post<T>(this.url(chemin), corps));
  }

  async chargerCaptcha(): Promise<void> {
    this.captcha.set(await this.lire<Captcha>('/captcha'));
    this.reponseCaptcha.set('');
  }

  private async chargerMedecins(): Promise<void> {
    this.medecins.set(await this.lire<Medecin[]>('/medecins'));
  }

  // 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
  private async verifierSession(): Promise<void> {
    try {
      this.utilisateur.set(await this.lire<Utilisateur>('/auth/moi'));
      await this.chargerMedecins();
    } catch (e) {
      // instanceof : un test de TYPE ; e.status : le code de statut (0 : pas de réponse)
      if (e instanceof HttpErrorResponse && e.status === 401) {
        await this.chargerCaptcha();
      } else if (e instanceof HttpErrorResponse && e.status !== 0) {
        this.erreurServeur.set(`Le serveur a répondu ${e.status}.`);
      } else {
        this.erreurServeur.set(`Le serveur JSON RdvMedecins est-il lancé (${config.urlServeur || 'port 8080'}) ?`);
      }
    }
    this.pret.set(true);
  }

  // le bouton "Se connecter" : la Commande exécute l'action et mémorise son erreur
  connecter(): void {
    this.connexion.executer(() => this.seConnecter());
  }

  // 2. la connexion
  private async seConnecter(): Promise<void> {
    try {
      const utilisateur = await this.poster<Utilisateur>('/auth/login', {
        login: this.login().trim(),
        password: this.password(),
        captchaId: this.captcha()?.id,
        captcha: this.reponseCaptcha(),
      });
      this.password.set('');
      this.utilisateur.set(utilisateur);
      await this.chargerMedecins();
    } catch (e) {
      if (!(e instanceof HttpErrorResponse)) throw e;
      // e.error : le corps JSON de la réponse d'erreur, { statusCode, cle, ... }
      const cle: string = e.error?.cle ?? 'ERRORS.SERVER_UNREACHABLE';
      await this.chargerCaptcha(); // chaque image ne sert qu'une fois
      // l'erreur que gardera la Commande
      throw new Error(`${e.status} - ${messages[cle] ?? cle}`);
    }
  }

  // le message de l'erreur gardée par la commande
  messageErreur(): string {
    const erreur = this.connexion.erreur();
    return erreur instanceof Error ? erreur.message : String(erreur);
  }

  // 4. la déconnexion
  async seDeconnecter(): Promise<void> {
    await this.poster('/auth/logout', {});
    this.utilisateur.set(null);
    this.medecins.set([]);
    await this.chargerCaptcha();
  }
}
<!-- ======================================================================
     ex23.page.html - connexion, session, déconnexion
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Exemple 23 - connexion au serveur</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  @if (!pret()) {
    <ion-note>Vérification de la session...</ion-note>
  } @else if (utilisateur(); as u) {
    <!-- connecté -->
    <ion-card>
      <ion-card-content>
        <div>Connecté : <strong>{{ u.nom }}</strong> ({{ u.login }}), rôle {{ u.role }}</div>
        <!-- 3. une requête protégée : le cookie l'a accompagnée -->
        <h3>GET /api/medecins</h3>
        @for (m of medecins(); track m.id) {
          <div>• {{ m.titre }} {{ m.prenom }} {{ m.nom }}</div>
        }
        <p class="ion-margin-top"><ion-note>Arrêtez puis relancez l'application : vous restez connecté.</ion-note></p>
        <ion-button fill="outline" shape="round" (click)="seDeconnecter()">Se déconnecter</ion-button>
      </ion-card-content>
    </ion-card>
  } @else if (erreurServeur() === null) {
    <!-- pas connecté : le formulaire -->
    <ion-card>
      <ion-card-content>
        <ion-input label="Login" labelPlacement="floating" [(ngModel)]="login" />
        <ion-input label="Mot de passe" labelPlacement="floating" type="password" [(ngModel)]="password" />
        <div class="captcha">
          @if (captcha(); as c) {
            <img [src]="imageCaptcha(c)" alt="captcha" />
          }
          <ion-button fill="clear" size="small" (click)="chargerCaptcha()">Autre image</ion-button>
        </div>
        <ion-input label="Recopiez les caractères de l'image" labelPlacement="floating" autocorrect="off"
          autocapitalize="off" [(ngModel)]="reponseCaptcha" />
        <!-- le bouton suit l'état de la commande -->
        <ion-button class="ion-margin-top" shape="round" [disabled]="connexion.enCours()"
          (click)="connecter()">
          {{ connexion.enCours() ? 'Connexion...' : 'Se connecter' }}
        </ion-button>
        @if (connexion.erreur() !== null) {
          <p><ion-text color="danger">{{ messageErreur() }}</ion-text></p>
        }
        <p><ion-note>Comptes : admin, pelissier, martin... (mot de passe = login).</ion-note></p>
      </ion-card-content>
    </ion-card>
  }

  @if (erreurServeur(); as message) {
    <ion-card class="erreur">
      <ion-card-content>{{ message }}</ion-card-content>
    </ion-card>
  }
</ion-content>

L'écran enchaîne les quatre étapes d'une session : au démarrage, qui est connecté ? ; la connexion ; une requête protégée ; la déconnexion.

  • lignes 29-34 (.ts) : les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera des dictionnaires) ;
  • lignes 36-47 (.ts) : la forme des réponses utilisées, décrite par deux interfaces (l'exemple 24 rangera ces types dans [commun/types.ts]) ;
  • lignes 67-76 (.ts) : l'état : l'utilisateur connecté ([null] : personne), les médecins, le captcha, la fin de la vérification de départ, une erreur de serveur, la commande de connexion, et les champs du formulaire ;
  • lignes 86-95 (.ts) : deux raccourcis. [firstValueFrom(observable)] (de [RxJS]) s'abonne à l'[Observable] de la requête et rend une [Promise] de sa première valeur, la réponse : on peut alors l'attendre avec [await], dans un code qui se lit de haut en bas. Si la requête échoue, la [Promise] est rejetée avec la [HttpErrorResponse], et [await] la lance comme une exception. Pour un POST (ligne 94), le corps est un objet : le client HTTP l'envoie en JSON, avec l'en-tête [Content-Type: application/json] que le serveur exige (sinon : 415) ;
  • lignes 107-122 (.ts) : 1. au démarrage, qui est connecté ? [GET /api/auth/moi] : 200, l'utilisateur (puis la liste des médecins) ; 401 (ligne 113), personne : on charge un captcha pour le formulaire. [e instanceof HttpErrorResponse] est un test de type : dans la branche où il est vrai, [TypeScript] sait que [e] a un [status]. Un autre code d'erreur (lignes 115-116) ou pas de réponse du tout (lignes 117-119, [status] 0) : un message ;
  • lignes 125-127 (.ts) : le bouton « Se connecter » confie l'action à la commande ;
  • lignes 130-149 (.ts) : 2. la connexion : [POST /api/auth/login]. En cas de succès, le navigateur (ou [Android]) a rangé le cookie : on garde l'utilisateur, et l'on charge les médecins. En cas de refus, [e.error] est le corps de la réponse d'erreur, déjà décodé : on y lit la clé du message (ligne 144 ; [?.] et [??] : une clé de secours si le corps n'en a pas). On charge une nouvelle image (chacune ne sert qu'une fois), puis on lance une erreur dont le message sera affiché (ligne 147) : c'est la commande qui l'attrape et la garde. Une exception qui n'est pas une [HttpErrorResponse] (une anomalie) est relancée telle quelle (ligne 142) ;
  • lignes 152-155 (.ts) : le message de l'erreur gardée par la commande ;
  • lignes 158-163 (.ts) : 4. la déconnexion : [POST /api/auth/logout] ; le serveur efface le cookie (il envoie un cookie vide, déjà expiré), et le navigateur l'oublie ;
  • lignes 15-16 (html) : tant que la vérification de départ n'est pas terminée ;
  • lignes 17-30 (html) : 3. connecté : l'utilisateur et la liste des médecins ([GET /api/medecins], une requête protégée : le cookie l'a accompagnée, sans une ligne de code) ;
  • lignes 31-55 (html) : pas connecté : le formulaire. Les champs sont reliés aux signaux par [[(ngModel)]] (exemple 05) ; le captcha s'affiche comme à l'exemple 22 (lignes 38-40) ;
  • lignes 46-52 (html) : le bouton suit l'état de la commande : désactivé et « Connexion... » pendant l'envoi ; l'erreur éventuelle en dessous ;
  • lignes 58-62 (html) : le serveur ne répond pas.

L'écran de connexion ; un mauvais mot de passe ; la connexion réussie :

Rechargez la page du navigateur (ou, sur le téléphone, arrêtez puis relancez l'application) : elle démarre connectée. Le cookie, gardé par le navigateur (ou par [Android]), est présenté à [GET /api/auth/moi], et le serveur répond par l'utilisateur. Le jeton expire au bout de deux heures : [GET /api/auth/moi] répondra alors 401, et l'écran de connexion reviendra.

9.3. Exemple 24 : une couche d'accès à l'API

Dans l'exemple 23, chaque requête répétait les mêmes gestes. Cet exemple les range dans trois couches, que l'étude de cas reprendra.

9.3.1. Les types

// =========================================================================
// src/app/commun/types.ts - les objets JSON échangés avec l'API RdvMedecins
// (cf. le chapitre sur la boîte noire, qui décrit chaque route)
// -------------------------------------------------------------------------
// La traduction, en types TypeScript, des réponses du serveur. Ce ne sont
// que des DESCRIPTIONS : elles disparaissent à la compilation et ne
// vérifient rien pendant l'exécution. Elles permettent au compilateur de
// vérifier que le code n'utilise que des champs qui existent, avec le bon
// type (et à l'éditeur de proposer leurs noms).
// =========================================================================

// un "type union" de chaînes : seules ces trois valeurs sont permises
export type Role = 'ADMIN' | 'DOCTOR' | 'USER';

// POST /api/auth/login, GET /api/auth/moi
export interface Utilisateur {
  login: string;
  nom: string; // nom affiché : "Marie PELISSIER"
  role: Role;
}

// GET /api/captcha
export interface Captcha {
  id: string;
  svg: string; // l'image, au format SVG (du texte)
}

// le texte SVG -> une adresse "data:" utilisable par <img [src]> : l'image
// est DANS l'adresse. Une image SVG affichée par <img> ne peut exécuter
// aucun script : pas de risque, même si son contenu était douteux
export function imageCaptcha(c: Captcha): string {
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.svg)}`;
}

// le nom d'une personne, sans rien d'autre
export interface Identite {
  titre: string;
  prenom: string;
  nom: string;
}

// un médecin ou un client (GET /api/medecins, GET /api/clients...)
// "extends" : une Personne a tous les champs d'une Identite, et d'autres
export interface Personne extends Identite {
  id: number;
  version: number; // verrou optimiste : à renvoyer avec une modification
  login?: string; // facultatif (?) : seulement pour l'ADMIN
}

// "Mme Alice DURAND"
export function nomComplet(i: Identite): string {
  return `${i.titre} ${i.prenom} ${i.nom}`;
}

// une ligne de GET /api/agenda : un créneau, et les DROITS de l'utilisateur connecté
export interface CreneauAgenda {
  id: number;
  hdebut: number;
  mdebut: number;
  hfin: number;
  mfin: number;
  libre: boolean;
  patient: Identite | null; // null : libre, ou occupé par un autre patient
  moi: boolean; // le rendez-vous du patient connecté
  reservable: boolean;
  idRv: number | null; // renseigné si l'utilisateur peut annuler
}

// "8:00 - 8:20"
export function horaire(c: CreneauAgenda): string {
  const hm = (h: number, m: number) => `${h}:${String(m).padStart(2, '0')}`;
  return `${hm(c.hdebut, c.mdebut)} - ${hm(c.hfin, c.mfin)}`;
}

// GET /api/agenda?idMedecin=...&jour=...
export interface Agenda {
  medecin: Personne;
  jour: string; // "yyyy-MM-dd"
  creneaux: CreneauAgenda[];
  info: string | null; // clé de traduction (patient qui a déjà un rendez-vous)
}

Les réponses du serveur, décrites en [TypeScript]. Ce ne sont que des descriptions : les interfaces disparaissent à la compilation, et ne vérifient rien pendant l'exécution. Elles permettent au compilateur de vérifier que le code n'utilise que des champs qui existent, avec le bon type, et à l'éditeur de proposer leurs noms.

  • ligne 13 : un type union de chaînes : une variable de type [Role] ne peut valoir que l'une de ces trois chaînes. Le compilateur refuse [role === 'ADMN'] ;
  • lignes 16-20 : l'utilisateur connecté ([POST /api/auth/login], [GET /api/auth/moi]) ;
  • lignes 44-48 : une personne (médecin ou client). [extends Identite] : elle a tous les champs d'une [Identite] (lignes 36-40), et d'autres ; [login?] : un champ facultatif (seul l'ADMIN le reçoit) ;
  • lignes 56-67 : une ligne de l'agenda. [Identite | null], [number | null] : avec ["strict": true], [TypeScript] oblige à tester ces valeurs avant de s'en servir ;
  • lignes 51-53, 70-73 : des fonctions de mise en forme : « Mme Alice DURAND », « 8:00 - 8:20 ». Ce ne sont pas des méthodes : les objets reçus du serveur sont des objets [JavaScript] ordinaires, sans classe ;
  • lignes 76-81 : l'agenda d'un médecin pour un jour.

C'est une différence de fond avec le client [Flutter]. En [Dart], le JSON décodé n'a pas de type ([dynamic]) : chaque classe avait un constructeur [depuisJson], qui vérifiait la forme du JSON reçu par le filtrage de [Dart] 3 (« pattern matching »), et le convertissait en objet. Ici, le compilateur croit ce qu'on lui annonce ([http.get<Agenda>(...)]) : une réponse inattendue ne serait détectée qu'au moment où l'affichage rencontrerait un champ absent. Pour un serveur qu'on maîtrise, c'est la pratique courante. Pour une API extérieure, on vérifierait les réponses avec une bibliothèque de « schémas » ([Zod], [Valibot]), qui fait à l'exécution ce que faisait le filtrage de [Dart].

9.3.2. Les erreurs

// =========================================================================
// src/app/commun/erreur-api.ts - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Toutes les réponses d'erreur du serveur RdvMedecins ont la même forme :
//   { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout
// (réseau coupé, serveur arrêté, mauvaise adresse), ou une réponse qui ne
// vient pas de notre serveur (une page d'erreur d'un proxy). Le client
// HTTP d'Angular signale tous ces échecs par une HttpErrorResponse ; on la
// convertit en UNE classe, ErreurApi, qui porte la clé de traduction
// envoyée par le serveur (et, s'il y en a, ses paramètres et l'erreur de
// chaque champ).
// =========================================================================

import { HttpErrorResponse } from '@angular/common/http';

// un objet { nom: texte }
export type Textes = Record<string, string>;

// "extends Error" : une erreur JavaScript à part entière, qu'on lance
// (throw) et qu'on attrape (catch) ; message vaut "409 ERRORS.LOGIN_TAKEN"
export class ErreurApi extends Error {
  constructor(
    readonly statusCode: number, // 0 : le serveur n'a pas répondu
    readonly cle: string, // ex : 'ERRORS.LOGIN_TAKEN'
    readonly params: Textes = {},
    readonly champs: Textes = {},
  ) {
    super(`${statusCode} ${cle}`);
    this.name = 'ErreurApi';
  }

  // une réponse en erreur du client HTTP d'Angular -> une ErreurApi
  static depuisReponse(reponse: HttpErrorResponse): ErreurApi {
    // status 0 : aucune réponse (réseau coupé, serveur arrêté, mauvaise adresse)
    if (reponse.status === 0) {
      return new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
    }
    // error : le corps de la réponse, déjà décodé s'il s'agit de JSON
    const corps: unknown = reponse.error;
    if (estUnObjet(corps) && typeof corps['cle'] === 'string') {
      return new ErreurApi(reponse.status, corps['cle'], textes(corps['params']), textes(corps['champs']));
    }
    // pas de clé : le proxy ou le réseau ont répondu à sa place
    return new ErreurApi(
      reponse.status,
      reponse.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR',
    );
  }
}

// un "garde de type" : si la fonction rend true, TypeScript sait que
// valeur est un objet dont on peut lire les propriétés
function estUnObjet(valeur: unknown): valeur is Record<string, unknown> {
  return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
}

// un objet JSON { "nom": "ERRORS..." } -> Textes (vide sinon)
function textes(json: unknown): Textes {
  if (!estUnObjet(json)) return {};
  return Object.fromEntries(Object.entries(json).map(([cle, valeur]) => [cle, String(valeur)]));
}

Toutes les réponses d'erreur du serveur ont la même forme. Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout, ou une réponse qui ne vient pas de notre serveur. Le client HTTP d'[Angular] signale tous ces échecs par une [HttpErrorResponse] ; on les convertit en UNE classe, [ErreurApi].

  • ligne 18 : [Record<string, string>] : un objet dont les propriétés sont des chaînes ;
  • lignes 22-31 : [extends Error] : une erreur [JavaScript] à part entière, qu'on lance ([throw]) et qu'on attrape ([catch]), avec sa pile d'appels. Les paramètres du constructeur précédés de [readonly] déclarent et initialisent les champs (un raccourci de [TypeScript]) : un statut (0 : pas de réponse), une clé de traduction, ses paramètres, l'erreur de chaque champ. Le message de l'erreur (ligne 29) est « 401 LOGIN.ERROR », pour un affichage rapide ;
  • lignes 34-49 : une [HttpErrorResponse] -> une [ErreurApi]. Statut 0 : pas de réponse (lignes 36-38). Sinon, [reponse.error] est le corps de la réponse, déjà décodé s'il s'agit de JSON (ligne 40) ; s'il a une clé [cle] de type chaîne, c'est notre serveur qui a répondu (lignes 41-42). Sinon, c'est un intermédiaire (proxy, réseau) qui a répondu à sa place (lignes 45-48) ;
  • lignes 54-56 : un garde de type ([valeur is Record<string, unknown>]) : une fonction dont le résultat [true] apprend au compilateur le type de son paramètre. Après [estUnObjet(corps)], il sait que [corps] est un objet dont on peut lire les propriétés. C'est ce que faisait, en une fois, le « if-case » de [Dart] ;
  • lignes 59-62 : un objet JSON { "nom": "ERRORS..." } -> des textes (vide si ce n'est pas un objet).

9.3.3. Les requêtes

// =========================================================================
// src/app/commun/api.ts - des requêtes vers l'API, en une ligne
// -------------------------------------------------------------------------
// Dans l'exemple 23, chaque requête répétait les mêmes gestes : adresse du
// serveur, préfixe /api, transformation de l'Observable en Promise, lecture
// du corps d'erreur... On les regroupe ici, dans un SERVICE :
//
//   const medecins = await this.api.get<Personne[]>('/medecins');
//   await this.api.post('/rendez-vous', { jour, idCreneau: 3 });
//
//   - en cas de succès, la Promise rend le corps JSON, DÉCODÉ ;
//   - en cas d'échec, elle est rejetée avec une ErreurApi.
// Toutes les requêtes passent par la méthode requete : c'est l'endroit où
// l'on traiterait un problème COMMUN à toutes (cf. l'étude de cas et la
// réponse 401). Utilisé par les exemples 24 et 25.
// =========================================================================

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { config } from './config';
import { ErreurApi } from './erreur-api';

export type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';

// providedIn: 'root' : un seul exemplaire, pour toute l'application
@Injectable({ providedIn: 'root' })
export class Api {
  private readonly http = inject(HttpClient);

  // <T> : le type de la réponse attendue, choisi par l'appelant
  async requete<T>(methode: Methode, url: string, corps?: unknown): Promise<T> {
    try {
      // http.request renvoie un Observable ; firstValueFrom le transforme en
      // Promise (la requête part à ce moment-là). Un objet corps est envoyé
      // en JSON (entête content-type: application/json, que le serveur exige
      // pour un POST) ; la réponse JSON est décodée (null si son corps est vide)
      return await firstValueFrom(this.http.request<T>(methode, `${config.urlServeur}/api${url}`, { body: corps }));
    } catch (e) {
      // le client HTTP signale TOUT échec (4xx, 5xx, pas de réponse) par une
      // HttpErrorResponse : on la convertit. Le reste (une anomalie) passe tel quel
      throw e instanceof HttpErrorResponse ? ErreurApi.depuisReponse(e) : e;
    }
  }

  get<T>(url: string): Promise<T> {
    return this.requete<T>('GET', url);
  }

  // un POST a toujours un corps JSON : {} par défaut
  post<T>(url: string, corps: unknown = {}): Promise<T> {
    return this.requete<T>('POST', url, corps);
  }

  put<T>(url: string, corps: unknown): Promise<T> {
    return this.requete<T>('PUT', url, corps);
  }

  delete<T>(url: string, corps?: unknown): Promise<T> {
    return this.requete<T>('DELETE', url, corps);
  }
}

Un service (exemple 14) qui fait, une fois pour toutes, les gestes que répétait l'exemple 23 :

  • ligne 27 : [@Injectable({ providedIn: 'root' })] : un seul exemplaire, pour toute l'application, créé à la première demande ;
  • lignes 32-44 : TOUTES les requêtes passent par cette méthode : l'adresse du serveur et le préfixe [/api], le corps en JSON ([http.request] accepte toutes les méthodes, et un corps même pour un DELETE), la réponse décodée et attendue avec [firstValueFrom] (ligne 38), les erreurs converties en [ErreurApi] (ligne 42). Le type [<T>] de la réponse est choisi par l'appelant ([api.get<Personne[]>('/medecins')]). C'est l'endroit où l'on traiterait un problème commun à toutes les requêtes (cf. l'étude de cas, et la réponse 401 d'une session expirée, qu'elle traite dans un intercepteur du client HTTP) ;
  • lignes 46-61 : quatre raccourcis. Un POST a toujours un corps JSON : [{}] par défaut (ligne 51).

9.3.4. Une barre de connexion

// =========================================================================
// src/app/commun/barre-connexion.ts - connexion / déconnexion, en une barre
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 23, réduit à une barre et réécrit avec le
// service Api. Le composant émet l'événement utilisateurChange avec
// l'utilisateur connecté (ou null) chaque fois que celui-ci change - y
// compris au démarrage, après avoir interrogé GET /api/auth/moi :
//
//     <app-barre-connexion (utilisateurChange)="surConnexion($event)" />
//
// Le service Api est obtenu par injection de dépendances (inject).
// =========================================================================

import { Component, inject, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { IonButton, IonCard, IonCardContent, IonIcon, IonInput, IonLabel, IonText } from '@ionic/angular';
import { addIcons } from 'ionicons';
import { logInOutline, person } from 'ionicons/icons';
import { Api } from './api';
import { ErreurApi } from './erreur-api';
import { Captcha, imageCaptcha, Utilisateur } from './types';

@Component({
  selector: 'app-barre-connexion',
  imports: [FormsModule, IonCard, IonCardContent, IonInput, IonButton, IonIcon, IonLabel, IonText],
  templateUrl: './barre-connexion.html',
  styles: `
    ion-card { --background: #eceff1; margin: 0 0 8px; }
    ion-card-content { padding: 8px 12px; }
    .ligne { display: flex; align-items: center; gap: 8px; }
    .ligne > ion-input, .ligne > ion-label { flex: 1; }
    img { height: 40px; }
    .erreur { font-size: 13px; }
  `,
})
export class BarreConnexion {
  private readonly api = inject(Api);
  readonly imageCaptcha = imageCaptcha;

  // l'événement émis vers le parent : l'utilisateur connecté, ou null
  readonly utilisateurChange = output<Utilisateur | null>();

  readonly utilisateur = signal<Utilisateur | null>(null);
  readonly captcha = signal<Captcha | null>(null);
  readonly login = signal('admin');
  readonly password = signal('');
  readonly reponseCaptcha = signal('');
  readonly erreur = signal<string | null>(null);

  constructor() {
    addIcons({ person, logInOutline });
    this.demarrer();
  }

  private changer(u: Utilisateur | null): void {
    this.utilisateur.set(u);
    this.utilisateurChange.emit(u);
  }

  private async nouveauCaptcha(): Promise<void> {
    this.captcha.set(await this.api.get<Captcha>('/captcha'));
    this.reponseCaptcha.set('');
  }

  // au démarrage : sommes-nous déjà connectés ?
  private async demarrer(): Promise<void> {
    try {
      this.changer(await this.api.get<Utilisateur>('/auth/moi'));
    } catch {
      this.changer(null); // 401 : personne n'est connecté
      try {
        await this.nouveauCaptcha();
      } catch {
        this.erreur.set('Le serveur JSON est-il lancé (port 8080) ?');
      }
    }
  }

  async seConnecter(): Promise<void> {
    this.erreur.set(null);
    try {
      const u = await this.api.post<Utilisateur>('/auth/login', {
        login: this.login().trim(),
        password: this.password(),
        captchaId: this.captcha()?.id,
        captcha: this.reponseCaptcha(),
      });
      this.password.set('');
      this.changer(u);
    } catch (e) {
      // e.message : "401 LOGIN.ERROR" (l'exemple 25 traduira la clé)
      this.erreur.set(e instanceof ErreurApi ? e.message : String(e));
      await this.nouveauCaptcha();
    }
  }

  async seDeconnecter(): Promise<void> {
    await this.api.post('/auth/logout');
    this.changer(null);
    await this.nouveauCaptcha();
  }
}
<!-- ======================================================================
     barre-connexion.html - l'utilisateur connecté, ou un petit formulaire
     ====================================================================== -->

<ion-card>
  <ion-card-content>
    @if (utilisateur(); as u) {
      <div class="ligne">
        <ion-icon name="person" />
        <ion-label>{{ u.nom }} ({{ u.role }})</ion-label>
        <ion-button fill="clear" size="small" (click)="seDeconnecter()">Se déconnecter</ion-button>
      </div>
    } @else {
      <div class="ligne">
        <ion-input label="login" labelPlacement="floating" [(ngModel)]="login" />
        <ion-input label="mot de passe" labelPlacement="floating" type="password" [(ngModel)]="password" />
      </div>
      <div class="ligne">
        <!-- l'image SVG du captcha, fabriquée par NOTRE serveur (cf. exemple 22) -->
        @if (captcha(); as c) {
          <img [src]="imageCaptcha(c)" alt="captcha" />
        }
        <ion-input label="captcha" labelPlacement="floating" autocorrect="off" autocapitalize="off"
          [(ngModel)]="reponseCaptcha" (keyup.enter)="seConnecter()" />
        <ion-button fill="clear" aria-label="Se connecter" (click)="seConnecter()">
          <ion-icon slot="icon-only" name="log-in-outline" />
        </ion-button>
      </div>
      @if (erreur(); as message) {
        <ion-text color="danger"><div class="erreur">{{ message }}</div></ion-text>
      }
    }
  </ion-card-content>
</ion-card>

Le formulaire de l'exemple 23, réduit à une barre et réécrit avec le service [Api]. Le composant émet l'événement [utilisateurChange] chaque fois que l'utilisateur connecté change, y compris au démarrage.

  • ligne 37 (.ts) : le service [Api], obtenu par injection (le [provider] de [Flutter]) ;
  • ligne 41 (.ts) : [output<Utilisateur | null>()] : un événement que le composant émet vers son parent (exemple 11), qui s'y abonne par [(utilisateurChange)="..."] ;
  • lignes 55-58 (.ts) : un changement d'utilisateur : on le garde, et on prévient le parent ([emit]) ;
  • lignes 66-77 (.ts) : au démarrage, qui est connecté ? [await this.api.get<Utilisateur>('/auth/moi')] : une ligne. Toute erreur signifie « personne » : on charge alors un captcha ; si cela échoue aussi, le serveur ne répond pas. [catch] sans variable : l'erreur elle-même ne sert pas ;
  • lignes 79-95 (.ts) : la connexion, en quelques lignes : les erreurs arrivent sous forme d'[ErreurApi], dont on affiche le message (« 401 LOGIN.ERROR » : l'exemple 25 traduira la clé) ;
  • lignes 7-12 (html) : connecté : l'utilisateur et son rôle, et le bouton « Se déconnecter » ;
  • lignes 13-31 (html) : le formulaire, sur deux lignes ([display: flex]) ; la touche Entrée dans le champ du captcha envoie le formulaire ([(keyup.enter)], ligne 24) ; le bouton n'a qu'une icône, et son nom pour les lecteurs d'écran ([aria-label]).

9.3.5. La couche d'accès à l'API

// =========================================================================
// src/app/ex24-couche-api/rdvmedecins.ts - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une méthode par route de l'API, typée. Les écrans ne connaissent ni les
// adresses, ni le client HTTP, ni le format JSON : ils appellent des
// méthodes qui renvoient des objets typés (Personne, Agenda...).
// C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API
// change (une adresse, un nom de champ), on ne modifie que ce fichier.
// Les guides d'architecture appellent cette couche un "repository".
// =========================================================================

import { HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Api } from '../commun/api';
import { Agenda, Personne } from '../commun/types';

@Injectable({ providedIn: 'root' })
export class RdvMedecins {
  private readonly api = inject(Api);

  getMedecins(): Promise<Personne[]> {
    return this.api.get<Personne[]>('/medecins');
  }

  getClients(): Promise<Personne[]> {
    return this.api.get<Personne[]>('/clients');
  }

  // HttpParams fabrique "idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
  getAgenda(idMedecin: number, jour: string): Promise<Agenda> {
    const parametres = new HttpParams().set('idMedecin', idMedecin).set('jour', jour);
    return this.api.get<Agenda>(`/agenda?${parametres.toString()}`);
  }

  // idClient? : un paramètre facultatif. ...(condition && objet) : la
  // propriété idClient n'est placée dans le corps que si idClient est donné
  async reserver(jour: string, idCreneau: number, idClient?: number | null): Promise<void> {
    await this.api.post('/rendez-vous', { jour, idCreneau, ...(idClient != null && { idClient }) });
  }

  async annuler(idRv: number): Promise<void> {
    await this.api.delete(`/rendez-vous/${idRv}`);
  }
}

Une méthode par route de l'API, typée. Les écrans ne connaissent ni les adresses, ni le client HTTP, ni le format JSON : ils appellent des méthodes qui renvoient des objets typés. Si l'API change (une adresse, un nom de champ), on ne modifie que ce fichier. Les guides d'architecture appellent cette couche un « repository ».

  • ligne 19 : la couche s'appuie sur le service [Api], qu'elle obtient par injection : [Angular] assemble lui-même les couches, là où le [main] du client [Flutter] les créait une à une ([RdvMedecins(api)]) ;
  • ligne 31 : [HttpParams] fabrique la chaîne « idMedecin=1&jour=2030-01-15 », en encodant ce qu'il faut ;
  • ligne 38 : [idClient] n'est placé dans le corps que s'il est donné. [...(condition && objet)] : si la condition est fausse, on « étale » [false], ce qui n'ajoute rien ; sinon, on étale l'objet [{ idClient }] (écriture abrégée de [{ idClient: idClient }]). C'était [?idClient] en [Dart] 3.8. [idClient != null] (avec [!=] et non [!==]) écarte à la fois [null] et [undefined].

9.3.6. Le modèle de vue

// =========================================================================
// src/app/ex24-couche-api/agenda-modele.ts - le "modèle de vue" de l'écran
// -------------------------------------------------------------------------
// Les guides d'architecture recommandent de séparer :
//   - la VUE : le gabarit et sa classe, qui affichent et transmettent les
//     actions de l'utilisateur ;
//   - le MODÈLE DE VUE (ViewModel) : un service qui contient l'état de
//     l'écran (le médecin choisi, le jour, l'agenda reçu...) dans des
//     SIGNAUX, les actions (choisir, réserver, annuler), et qui appelle la
//     couche d'accès à l'API.
// La vue n'a plus de logique : elle est simple à lire ; le modèle de vue
// n'a pas de gabarit : il est simple à tester.
// Il est fourni par la PAGE (providers: [AgendaModele]) : il naît et meurt
// avec elle.
//
// Il fait aussi ce qu'on n'a pas envie de réécrire à chaque écran :
//   - un CACHE : l'agenda de chaque (médecin, jour) déjà lu est gardé ; y
//     revenir l'affiche aussitôt, pendant qu'on le relit en arrière-plan ;
//   - les réponses PÉRIMÉES sont ignorées (cf. exemple 19).
// =========================================================================

import { computed, inject, Injectable, signal } from '@angular/core';
import { ErreurApi } from '../commun/erreur-api';
import { Agenda, CreneauAgenda, horaire, Personne, Utilisateur } from '../commun/types';
import { RdvMedecins } from './rdvmedecins';

// une date -> "2026-10-07" (le jour LOCAL ; toISOString donnerait le jour à Greenwich)
export function formatJour(d: Date): string {
  const deuxChiffres = (n: number) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${deuxChiffres(d.getMonth() + 1)}-${deuxChiffres(d.getDate())}`;
}

// demain : le jour d'aujourd'hui, plus un
function demain(): Date {
  const d = new Date();
  d.setDate(d.getDate() + 1);
  return d;
}

export interface Message {
  succes: boolean;
  texte: string;
}

@Injectable()
export class AgendaModele {
  private readonly rdvmedecins = inject(RdvMedecins);

  // ---- l'état ----
  readonly utilisateur = signal<Utilisateur | null>(null);
  readonly medecins = signal<Personne[]>([]);
  readonly clients = signal<Personne[]>([]);
  readonly idMedecin = signal<number | null>(null);
  readonly jour = signal(formatJour(demain()));
  readonly idClient = signal<number | null>(null);
  readonly agenda = signal<Agenda | null>(null);
  readonly chargement = signal(false);
  readonly message = signal<Message | null>(null);

  private readonly cache = new Map<string, Agenda>();
  private numeroLecture = 0;

  // ---- des valeurs calculées ----
  readonly connecte = computed(() => this.utilisateur() !== null);
  readonly gestionnaire = computed(() => this.connecte() && this.utilisateur()?.role !== 'USER');

  // ---- les actions ----
  async changerUtilisateur(u: Utilisateur | null): Promise<void> {
    this.utilisateur.set(u);
    this.medecins.set([]);
    this.clients.set([]);
    this.agenda.set(null);
    this.idMedecin.set(null);
    this.message.set(null);
    this.cache.clear(); // les droits ont changé : on oublie tout
    if (u === null) return;
    this.medecins.set(await this.rdvmedecins.getMedecins());
    // un USER recevrait 403
    if (this.gestionnaire()) this.clients.set(await this.rdvmedecins.getClients());
  }

  choisirMedecin(id: number): void {
    this.idMedecin.set(id);
    this.message.set(null);
    this.lireAgenda();
  }

  choisirJour(jour: string): void {
    this.jour.set(jour);
    this.message.set(null);
    this.lireAgenda();
  }

  choisirClient(id: number | null): void {
    this.idClient.set(id);
  }

  reserver(c: CreneauAgenda): Promise<void> {
    return this.agir(
      () => this.rdvmedecins.reserver(this.jour(), c.id, this.gestionnaire() ? this.idClient() : null),
      `Créneau ${horaire(c)} réservé.`,
    );
  }

  annuler(c: CreneauAgenda): Promise<void> {
    return this.agir(() => this.rdvmedecins.annuler(c.idRv!), `Rendez-vous de ${horaire(c)} annulé.`);
  }

  // une action, puis TOUJOURS une relecture de l'agenda : c'est le SERVEUR qui fait foi
  private async agir(action: () => Promise<void>, succes: string): Promise<void> {
    try {
      await action();
      this.message.set({ succes: true, texte: succes });
    } catch (e) {
      this.message.set({ succes: false, texte: e instanceof ErreurApi ? e.message : String(e) });
    }
    await this.lireAgenda();
  }

  private async lireAgenda(): Promise<void> {
    const id = this.idMedecin();
    if (id === null) return;
    const cle = `${id}/${this.jour()}`;
    const numero = ++this.numeroLecture;
    // le cache d'abord (ou rien), puis la lecture
    this.agenda.set(this.cache.get(cle) ?? null);
    this.chargement.set(true);
    try {
      const lu = await this.rdvmedecins.getAgenda(id, this.jour());
      if (numero !== this.numeroLecture) return; // une lecture plus récente a été lancée
      this.cache.set(cle, lu);
      this.agenda.set(lu);
    } catch (e) {
      if (numero !== this.numeroLecture) return;
      this.agenda.set(null);
      this.message.set({ succes: false, texte: e instanceof ErreurApi ? e.message : String(e) });
    }
    this.chargement.set(false);
  }
}

Les guides d'architecture recommandent de séparer la vue (le gabarit et sa classe, qui affichent et transmettent les actions de l'utilisateur) et le modèle de vue (un service qui contient l'état de l'écran et ses actions, et qui appelle la couche d'accès à l'API). La vue n'a plus de logique ; le modèle de vue n'a pas de gabarit : il se teste sans écran.

  • lignes 28-38 : le jour au format du serveur (« 2026-10-07 »), et demain. On n'utilise pas [toISOString()], qui donnerait le jour à Greenwich : à 0 h 30 à Paris, c'est encore la veille ;
  • ligne 45 : [@Injectable()], sans [providedIn] : le service n'est pas fourni à toute l'application, mais par la page qui le déclare dans ses [providers] (cf. l'écran). Il naît et meurt avec elle ;
  • lignes 50-58 : l'état de l'écran, dans des signaux ;
  • lignes 60-61 : un cache : l'agenda de chaque (médecin, jour) déjà lu est gardé ; y revenir l'affiche aussitôt, pendant qu'on le relit. Et le numéro de la dernière lecture, pour ignorer les réponses périmées (cf. exemple 19). C'est, en quelques lignes, ce que fait la bibliothèque [TanStack Query] du cours [React] ;
  • lignes 64-65 : des valeurs calculées ([computed]) : connecté ? gestionnaire (ADMIN ou DOCTOR) ?
  • lignes 68-80 : un nouvel utilisateur : on oublie tout (les droits ont changé), puis on lit les médecins et, pour l'ADMIN et le DOCTOR, les clients (un USER recevrait 403) ;
  • lignes 98-107 : réserver, annuler. Le [!] de [c.idRv!] (ligne 106) affirme au compilateur que la valeur n'est pas [null] : le bouton « Annuler » n'est affiché que dans ce cas ;
  • lignes 110-118 : une action, puis TOUJOURS une relecture de l'agenda : c'est le serveur qui fait foi ;
  • lignes 120-139 : la lecture de l'agenda : le cache d'abord, ou rien (ligne 126), puis la réponse du serveur, sauf si une lecture plus récente a été lancée entre-temps (ligne 130).

9.3.7. L'écran

// =========================================================================
// src/app/ex24-couche-api/ex24.page.ts - un agenda, avec la couche d'accès à l'API
// -------------------------------------------------------------------------
// Connectez-vous (barre du haut), choisissez un médecin et un jour :
// l'agenda s'affiche. ADMIN et DOCTOR peuvent réserver pour un patient et
// annuler ; un patient (martin...) réserve pour lui-même. Chaque action
// se termine par une relecture de l'agenda.
// Trois couches, du bas vers le haut :
//   Api (commun/api.ts)                 les requêtes HTTP, les erreurs ;
//   RdvMedecins (rdvmedecins.ts)        une méthode typée par route de l'API ;
//   AgendaModele (agenda-modele.ts)     l'état et les actions de l'écran ;
// et la vue (cette classe et son gabarit), qui ne fait qu'afficher le modèle.
// =========================================================================

import { Component, inject } from '@angular/core';
import {
  IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonChip, IonContent, IonDatetime, IonHeader,
  IonIcon, IonItem, IonLabel, IonList, IonModal, IonProgressBar, IonSelect, IonSelectOption, IonText, IonTitle,
  IonToolbar,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { addCircleOutline, calendarOutline, closeCircleOutline } from 'ionicons/icons';
import { BarreConnexion } from '../commun/barre-connexion';
import { horaire, nomComplet } from '../commun/types';
import { AgendaModele } from './agenda-modele';

@Component({
  selector: 'app-ex24',
  imports: [
    IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonSelect, IonSelectOption, IonButton,
    IonIcon, IonModal, IonDatetime, IonCard, IonCardContent, IonProgressBar, IonText, IonList, IonItem, IonLabel,
    IonChip, BarreConnexion,
  ],
  templateUrl: './ex24.page.html',
  // le modèle de vue est fourni par la PAGE : un exemplaire par page, détruit avec elle
  providers: [AgendaModele],
  styles: `
    .jour { display: flex; align-items: center; }
    ion-modal.calendrier { --width: fit-content; --height: fit-content; --border-radius: 8px; }
    .message { margin: 8px 0; }
    .succes { --background: #e8f5e9; --color: #333; }
    .echec { --background: #ffebee; --color: #333; }
    .titre-agenda { font-weight: bold; font-size: 14px; margin: 8px 0; }
    .horaire { flex: 0 0 90px; font-size: 13px; }
    .libre { --background: #c8e6c9; }
    .moi { --background: #c5cae9; }
    .autre { --background: #e0e0e0; }
    ion-item ion-icon { font-size: 26px; }
  `,
})
export class Ex24Page {
  readonly modele = inject(AgendaModele);
  readonly horaire = horaire;
  readonly nomComplet = nomComplet;

  constructor() {
    addIcons({ calendarOutline, addCircleOutline, closeCircleOutline });
  }

  // ion-datetime rend "2026-10-07" (presentation="date"), ou un tableau si
  // plusieurs dates peuvent être choisies : on ne garde qu'une chaîne
  choisirJour(valeur: string | string[] | null | undefined): void {
    if (typeof valeur === 'string') this.modele.choisirJour(valeur.substring(0, 10));
  }
}
<!-- ======================================================================
     ex24.page.html - la vue : elle ne fait qu'afficher le modèle de vue
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Exemple 24 - une couche d'accès à l'API</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <!-- la barre de connexion prévient le modèle de vue -->
  <app-barre-connexion (utilisateurChange)="modele.changerUtilisateur($event)" />

  @if (modele.connecte()) {
    <ion-select placeholder="-- choisir un médecin --" interface="popover" class="choix-medecin" aria-label="médecin"
      [value]="modele.idMedecin()" (ionChange)="modele.choisirMedecin($event.detail.value)">
      @for (m of modele.medecins(); track m.id) {
        <ion-select-option [value]="m.id">{{ nomComplet(m) }}</ion-select-option>
      }
    </ion-select>

    <div class="jour">
      Jour : {{ modele.jour() }}
      <ion-button id="calendrier24" fill="clear" aria-label="choisir le jour">
        <ion-icon slot="icon-only" name="calendar-outline" />
      </ion-button>
    </div>
    <ion-modal class="calendrier" trigger="calendrier24">
      <ng-template>
        <ion-datetime presentation="date" locale="fr-FR" [firstDayOfWeek]="1" [showDefaultButtons]="true"
          doneText="OK" cancelText="Annuler" [value]="modele.jour()" (ionChange)="choisirJour($event.detail.value)" />
      </ng-template>
    </ion-modal>

    <!-- ADMIN et DOCTOR : le patient pour qui l'on réserve -->
    @if (modele.gestionnaire()) {
      <ion-select interface="popover" class="choix-patient" aria-label="patient" [value]="modele.idClient()"
        (ionChange)="modele.choisirClient($event.detail.value)">
        <ion-select-option [value]="null">-- patient à inscrire --</ion-select-option>
        @for (c of modele.clients(); track c.id) {
          <ion-select-option [value]="c.id">{{ nomComplet(c) }}</ion-select-option>
        }
      </ion-select>
    }

    @if (modele.message(); as message) {
      <ion-card class="message" [class.succes]="message.succes" [class.echec]="!message.succes">
        <ion-card-content>{{ message.texte }}</ion-card-content>
      </ion-card>
    }
    @if (modele.chargement()) {
      <ion-progress-bar type="indeterminate" />
    }

    @if (modele.agenda(); as agenda) {
      <!-- info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir -->
      @if (agenda.info) {
        <ion-text color="primary"><div>{{ agenda.info }}</div></ion-text>
      }
      <div class="titre-agenda">Agenda du {{ agenda.jour }} - {{ nomComplet(agenda.medecin) }}</div>
      <ion-list lines="none">
        <!-- une ligne par créneau : l'horaire, l'occupant, le bouton permis -->
        @for (c of agenda.creneaux; track c.id) {
          <ion-item>
            <ion-label class="horaire">{{ horaire(c) }}</ion-label>
            @if (c.libre) {
              <ion-chip class="libre">Libre</ion-chip>
            } @else {
              <ion-chip [class.moi]="c.moi" [class.autre]="!c.moi">
                {{ c.patient ? nomComplet(c.patient) : 'Réservé' }}
              </ion-chip>
            }
            <!-- les boutons suivent les droits calculés par le serveur -->
            @if (c.reservable) {
              <ion-button slot="end" fill="clear" color="success" aria-label="Réserver" (click)="modele.reserver(c)">
                <ion-icon slot="icon-only" name="add-circle-outline" />
              </ion-button>
            } @else if (c.idRv !== null) {
              <ion-button slot="end" fill="clear" color="danger" aria-label="Annuler" (click)="modele.annuler(c)">
                <ion-icon slot="icon-only" name="close-circle-outline" />
              </ion-button>
            }
          </ion-item>
        }
      </ion-list>
    }
  }
</ion-content>
  • ligne 36 (.ts) : [providers: [AgendaModele]] : la page fournit le modèle de vue, à elle-même et aux composants qu'elle contient. Les deux autres couches ([Api], [RdvMedecins]) sont fournies à toute l'application ([providedIn: 'root']) ;
  • ligne 52 (.ts) : la vue obtient le modèle de vue ; elle n'a pas d'autre état ;
  • lignes 53-54 (.ts) : les fonctions de mise en forme, rendues accessibles au gabarit (un gabarit ne voit que les membres de sa classe) ;
  • lignes 62-64 (.ts) : [ion-datetime] rend la date choisie (« 2026-10-07 »), ou un tableau si plusieurs dates pouvaient être choisies : on ne garde qu'une chaîne ;
  • ligne 16 (html) : la barre de connexion prévient le modèle de vue ; [$event] est la valeur émise, l'utilisateur ou [null] ;
  • lignes 19-24 (html) : le choix du médecin : un [<ion-select>] dont la liste s'ouvre dans une fenêtre surgissante ([interface="popover"]) ; sa valeur est lue dans le modèle, et chaque choix ([ionChange]) appelle une action du modèle ;
  • lignes 26-37 (html) : le jour, et un calendrier dans une fenêtre modale (comme à l'exemple 21) : [ion-datetime], en français, la semaine commençant le lundi ; ses boutons OK et Annuler ([showDefaultButtons]) ferment la fenêtre, et OK valide le jour ([ionChange]) ;
  • lignes 40-48 (html) : pour l'ADMIN et le DOCTOR, le patient pour qui l'on réserve ;
  • lignes 50-57 (html) : le message de la dernière action (vert ou rouge : [[class.succes]] ajoute la classe CSS quand la condition est vraie), et la barre de progression pendant une lecture ;
  • lignes 61-63 (html) : [info], la clé envoyée par le serveur quand le patient a déjà un rendez-vous à venir. Elle est affichée telle quelle : l'exemple 25 apprendra à traduire les clés ;
  • lignes 67-88 (html) : une ligne par créneau : l'horaire, un [<ion-chip>] (une « puce ») « Libre » ou au nom du patient (en indigo si c'est le rendez-vous du patient connecté), et le bouton permis. Les boutons suivent les droits calculés par le serveur ([reservable], [idRv]) : l'écran ne décide de rien.

Connecté comme [admin], l'agenda de Mme PELISSIER pour le lendemain ; on réserve un créneau pour Mr Jean MARTIN ; connecté comme [martin] (rôle USER), le même agenda :

Le patient [martin] voit son rendez-vous (en indigo, avec le bouton d'annulation) ; la clé [AGENDA.ALREADY_BOOKED_INFO] lui signale qu'il a déjà un rendez-vous à venir : aucun autre créneau n'est réservable. Un rendez-vous d'un autre patient apparaîtrait « Réservé », sans nom : le serveur ne le lui envoie pas.

9.4. Exemple 25 : les erreurs renvoyées par le serveur

9.4.1. Les dictionnaires

{
  "FORM": {
    "TITRE_AJOUT": "Ajouter un client",
    "TITRE_MODIF": "Modifier un client",
    "TITRE": "Titre",
    "PRENOM": "Prénom",
    "NOM": "Nom",
    "LOGIN": "Login",
    "PASSWORD": "Mot de passe",
    "ENREGISTRER": "Enregistrer",
    "ANNULER": "Annuler",
    "MODIFIER": "Modifier",
    "SUPPRIMER": "Supprimer",
    "CONFIRMER_SUPPRESSION": "Supprimer le client {{nom}}, son compte et ses rendez-vous ?",
    "AJOUTE": "Le client {{nom}} a été ajouté (login : {{login}}).",
    "MODIFIE": "Le client {{nom}} a été modifié.",
    "SUPPRIME": "Le client {{nom}} a été supprimé.",
    "ADMIN_SEULEMENT": "Connectez-vous avec le compte admin : seul le rôle ADMIN gère les clients.",
    "TITRE_PAGE": "Exemple 25 - les erreurs du serveur",
    "NOUVEAU": "Nouveau client"
  },
  "ERRORS": {
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Rechargez-la avant de recommencer.",
    "FORBIDDEN": "Votre rôle ne vous permet pas cette action.",
    "AUTH_REQUIRED": "Connectez-vous d'abord.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "SERVER_UNREACHABLE": "Le serveur ne répond pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite.",
    "WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image."
  }
}

Les messages d'erreur du serveur sont des clés (« ERRORS.LASTNAME_REQUIRED ») : le dictionnaire les traduit. Ce sont les dictionnaires de l'exemple [Flutter], sans changement ; le dictionnaire anglais, [ex25-en.json], a les mêmes clés. Comme ceux de l'exemple 21, ils sont dans le dossier [public/i18n/] du projet, livré tel quel avec l'application.

9.4.2. L'écran

// =========================================================================
// src/app/ex25-erreurs-serveur/ex25.page.ts - afficher les erreurs du SERVEUR
// -------------------------------------------------------------------------
// La gestion des clients (compte admin). Ce formulaire ne fait
// VOLONTAIREMENT aucune validation sur le téléphone : tout est envoyé au
// serveur, qui répond, en cas de refus, par une erreur JSON (convertie en
// ErreurApi par commun/api.ts) :
//
//   400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
//   409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
//   409 { cle: 'ERRORS.CONCURRENT_UPDATE' }         (verrou optimiste)
//
// L'erreur de CHAQUE champ (ErreurApi.champs) est donnée à son contrôle de
// formulaire (setErrors) : ion-input l'affiche sous le champ (errorText).
// Son message est la CLÉ envoyée par le serveur, que l'écran traduit
// (dictionnaires de l'exemple 21). Modifier un champ efface son erreur.
// Essayez : un nom vide, un titre "Dr", un homonyme (Alice DURAND), un
// login déjà pris (martin), la même fiche modifiée sur deux appareils...
// =========================================================================

import { Component, inject, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
import {
  IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonContent, IonHeader,
  IonIcon, IonInput, IonItem, IonLabel, IonList, IonNote, IonPopover, IonSelect, IonSelectOption, IonText, IonTitle,
  IonToolbar,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { createOutline, languageOutline, trashOutline } from 'ionicons/icons';
import { Api } from '../commun/api';
import { BarreConnexion } from '../commun/barre-connexion';
import { ErreurApi, Textes } from '../commun/erreur-api';
import { nomComplet, Personne, Utilisateur } from '../commun/types';
import { injecterConfirmation } from '../ex16-confirmation/confirmation';
import { langues, Traductions } from '../ex21-i18n/traductions';
import { Commande } from '../ex23-connexion/commande';

// les champs du formulaire, et un message à traduire (une clé et ses paramètres)
type Champ = 'titre' | 'prenom' | 'nom' | 'login' | 'password';
interface Succes {
  cle: string;
  params: Textes;
}

@Component({
  selector: 'app-ex25',
  imports: [
    ReactiveFormsModule, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonButton, IonIcon, IonPopover,
    IonContent, IonList, IonItem, IonLabel, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonSelect,
    IonSelectOption, IonInput, IonText, IonNote, BarreConnexion,
  ],
  templateUrl: './ex25.page.html',
  // les traductions de la page, avec les dictionnaires ex25-fr.json et ex25-en.json
  providers: [{ provide: Traductions, useFactory: () => new Traductions('ex25') }],
  styles: `
    .avertissement { --background: #fff8e1; --color: #333; }
    .message { margin-top: 12px; }
    .statut { font-size: 12px; }
  `,
})
export class Ex25Page {
  private readonly api = inject(Api);
  private readonly confirmer = injecterConfirmation();
  readonly tr = inject(Traductions);
  readonly langues = langues;
  readonly titres = ['Mr', 'Mme', 'Melle', 'Dr']; // "Dr" : le serveur le refusera
  readonly nomComplet = nomComplet;

  readonly utilisateur = signal<Utilisateur | null>(null);
  readonly clients = signal<Personne[]>([]);

  // le formulaire : AUCUNE règle de validation, tout est vérifié par le serveur
  readonly formulaire = inject(NonNullableFormBuilder).group({
    titre: ['Mr'],
    prenom: [''],
    nom: [''],
    login: [''],
    password: [''],
  });
  // null : création ; sinon, l'id du client modifié, et la version lue avec sa fiche
  readonly idEdite = signal<number | null>(null);
  private version = 0;

  // le message général de la dernière erreur, et celui d'un succès
  readonly erreurPage = signal<ErreurApi | null>(null);
  readonly succes = signal<Succes | null>(null);
  // l'envoi au serveur : une Commande (exemple 23), qui dit s'il est en cours
  readonly envoi = new Commande();

  constructor() {
    addIcons({ languageOutline, createOutline, trashOutline });
    this.tr.demarrer();
  }

  estAdmin(): boolean {
    return this.utilisateur()?.role === 'ADMIN';
  }

  private async recharger(): Promise<void> {
    this.clients.set(await this.api.get<Personne[]>('/clients'));
  }

  async surConnexion(u: Utilisateur | null): Promise<void> {
    this.utilisateur.set(u);
    this.clients.set([]);
    if (u?.role === 'ADMIN') await this.recharger();
  }

  // l'erreur d'un champ, renvoyée par le serveur, traduite ('' : pas d'erreur)
  erreurChamp(champ: Champ): string {
    const cle = this.formulaire.controls[champ].errors?.['serveur'];
    return typeof cle === 'string' ? this.tr.t(cle) : '';
  }

  private effacerMessages(): void {
    this.erreurPage.set(null);
    this.succes.set(null);
  }

  nouveau(): void {
    this.idEdite.set(null);
    this.version = 0;
    // reset : les valeurs de départ, et plus aucune erreur
    this.formulaire.reset();
    this.effacerMessages();
  }

  editer(c: Personne): void {
    this.idEdite.set(c.id);
    // la VERSION lue avec la fiche repart avec la modification : si quelqu'un
    // a modifié la fiche entre-temps, le serveur répondra 409 CONCURRENT_UPDATE
    this.version = c.version;
    this.formulaire.reset({ titre: c.titre, prenom: c.prenom, nom: c.nom, login: c.login ?? '', password: '' });
    this.effacerMessages();
  }

  // le bouton "Enregistrer"
  envoyer(): void {
    this.envoi.executer(() => this.enregistrer());
  }

  private async enregistrer(): Promise<void> {
    this.effacerMessages();
    const saisie = { ...this.formulaire.getRawValue(), version: this.version };
    const id = this.idEdite();
    try {
      const client =
        id === null
          ? await this.api.post<Personne>('/clients', saisie)
          : await this.api.put<Personne>(`/clients/${id}`, saisie);
      this.nouveau(); // formulaire vidé, messages effacés...
      // ... sauf celui-ci
      this.succes.set({
        cle: id === null ? 'FORM.AJOUTE' : 'FORM.MODIFIE',
        params: { nom: `${client.prenom} ${client.nom}`, login: client.login ?? '' },
      });
      await this.recharger();
    } catch (e) {
      if (!(e instanceof ErreurApi)) throw e;
      this.erreurPage.set(e); // le message général
      // l'erreur de chaque champ : elle est donnée à son contrôle, qui
      // devient invalide (ng-invalid) ; markAsTouched le marque "touché"
      // (ng-touched) : Ionic affiche alors son errorText
      for (const [champ, cle] of Object.entries(e.champs)) {
        const controle = this.formulaire.get(champ);
        controle?.setErrors({ serveur: cle });
        controle?.markAsTouched();
      }
    }
  }

  async supprimer(c: Personne): Promise<void> {
    const nom = `${c.prenom} ${c.nom}`;
    if (!(await this.confirmer(this.tr.t('FORM.CONFIRMER_SUPPRESSION', { nom })))) return;
    this.effacerMessages();
    try {
      await this.api.delete(`/clients/${c.id}`);
      this.succes.set({ cle: 'FORM.SUPPRIME', params: { nom } });
    } catch (e) {
      if (e instanceof ErreurApi) this.erreurPage.set(e);
    }
    await this.recharger();
  }
}
<!-- ======================================================================
     ex25.page.html - la gestion des clients, et les erreurs du serveur
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>{{ tr.t('FORM.TITRE_PAGE') }}</ion-title>
    <!-- la langue : un menu, dans une fenêtre surgissante (ion-popover) -->
    <ion-buttons slot="end">
      <ion-button id="langue25" aria-label="langue">
        <ion-icon slot="icon-only" name="language-outline" />
      </ion-button>
    </ion-buttons>
    <ion-popover trigger="langue25" [dismissOnSelect]="true">
      <ng-template>
        <ion-list>
          @for (l of langues; track l) {
            <ion-item [button]="true" (click)="tr.changerLangue(l)">{{ l.toUpperCase() }}</ion-item>
          }
        </ion-list>
      </ng-template>
    </ion-popover>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <app-barre-connexion (utilisateurChange)="surConnexion($event)" />

  @if (utilisateur() !== null && !estAdmin()) {
    <ion-card class="avertissement">
      <ion-card-content>{{ tr.t('FORM.ADMIN_SEULEMENT') }}</ion-card-content>
    </ion-card>
  }

  @if (estAdmin()) {
    <ion-list>
      @for (c of clients(); track c.id) {
        <ion-item>
          <ion-label>
            {{ nomComplet(c) }}
            <p>{{ c.login }}</p>
          </ion-label>
          <ion-button slot="end" fill="clear" color="dark" [attr.aria-label]="tr.t('FORM.MODIFIER')"
            (click)="editer(c)">
            <ion-icon slot="icon-only" name="create-outline" />
          </ion-button>
          <ion-button slot="end" fill="clear" color="danger" [attr.aria-label]="tr.t('FORM.SUPPRIMER')"
            (click)="supprimer(c)">
            <ion-icon slot="icon-only" name="trash-outline" />
          </ion-button>
        </ion-item>
      }
    </ion-list>

    <ion-card>
      <ion-card-header>
        <ion-card-title>{{ tr.t(idEdite() === null ? 'FORM.TITRE_AJOUT' : 'FORM.TITRE_MODIF') }}</ion-card-title>
      </ion-card-header>
      <ion-card-content>
        <!-- pas de (ngSubmit) : le bouton Enregistrer appelle envoyer() -->
        <form [formGroup]="formulaire">
          <!-- errorText : l'erreur du champ renvoyée par le serveur, traduite. Ionic
               l'affiche quand le contrôle est invalide (ion-invalid) et touché (ion-touched) -->
          <ion-select [label]="tr.t('FORM.TITRE')" labelPlacement="floating" interface="popover"
            formControlName="titre" [errorText]="erreurChamp('titre')">
            @for (t of titres; track t) {
              <ion-select-option [value]="t">{{ t }}</ion-select-option>
            }
          </ion-select>
          <ion-input [label]="tr.t('FORM.PRENOM')" labelPlacement="floating" formControlName="prenom"
            [errorText]="erreurChamp('prenom')" />
          <ion-input [label]="tr.t('FORM.NOM')" labelPlacement="floating" formControlName="nom"
            [errorText]="erreurChamp('nom')" />
          <ion-input [label]="tr.t('FORM.LOGIN')" labelPlacement="floating" formControlName="login"
            [errorText]="erreurChamp('login')" />
          <ion-input [label]="tr.t('FORM.PASSWORD')" labelPlacement="floating" type="password"
            formControlName="password" [errorText]="erreurChamp('password')" />
        </form>

        <ion-button class="ion-margin-top" shape="round" [disabled]="envoi.enCours()" (click)="envoyer()">
          {{ tr.t('FORM.ENREGISTRER') }}
        </ion-button>
        <ion-button class="ion-margin-top" shape="round" fill="outline" (click)="nouveau()">
          {{ tr.t('FORM.ANNULER') }}
        </ion-button>

        @if (succes(); as s) {
          <div class="message"><ion-text color="success">{{ tr.t(s.cle, s.params) }}</ion-text></div>
        }
        <!-- le message général, et le statut HTTP, pour comprendre -->
        @if (erreurPage(); as e) {
          <div class="message">
            <ion-text color="danger">{{ tr.t(e.cle, e.params) }}</ion-text>
            <ion-note class="statut"> ({{ e.message }})</ion-note>
          </div>
        }
      </ion-card-content>
    </ion-card>
  }
</ion-content>

La gestion des clients (compte admin). Ce formulaire ne fait VOLONTAIREMENT aucune validation sur le téléphone : tout est envoyé au serveur, qui répond, en cas de refus, par une erreur JSON, convertie en [ErreurApi] par le service [Api].

  • lignes 34-36 (.ts) : la page réutilise trois outils des exemples précédents, en les important de leur dossier : la confirmation de l'exemple 16, les traductions de l'exemple 21, la commande de l'exemple 23 (le client [Flutter] faisait de même). La classe [Traductions] de l'exemple 21 a été écrite pour cela : ce n'est pas un service à exemplaire unique, mais une classe paramétrée par le préfixe de ses dictionnaires. La ligne 54 en crée un exemplaire pour cette page, avec les dictionnaires [ex25-fr.json] et [ex25-en.json]. Pourquoi ne pas la déplacer dans [commun/] ? Elle y aurait sa place dans une application ; dans ce cours, chaque exemple se lit dans l'ordre, et l'exemple 21 reste celui qui la présente ;
  • ligne 63 (.ts) : la fonction de confirmation de l'exemple 16 ([AlertController]) ;
  • ligne 66 (.ts) : un titre « Dr », que le serveur refusera : pour voir [ERRORS.INVALID_TITLE] ;
  • lignes 73-79 (.ts) : un formulaire réactif (exemple 08), SANS aucune règle de validation ;
  • lignes 81-82 (.ts) : le client modifié ([null] : une création), et la version lue avec sa fiche ;
  • lignes 85-88 (.ts) : le message général de la dernière erreur, celui d'un succès (une clé et ses paramètres, traduits à l'affichage : ils suivent un changement de langue), et l'envoi, une [Commande] (exemple 23) ;
  • lignes 110-113 (.ts) : le message d'erreur d'un champ. L'erreur est rangée dans le contrôle du champ, sous le nom [serveur] (cf. plus bas) ; sa valeur est la clé envoyée par le serveur, qu'on traduit ;
  • lignes 120-126 (.ts) : un nouveau client : [formulaire.reset()] remet les valeurs de départ (le formulaire est « non nullable », exemple 08) et efface toutes les erreurs ;
  • lignes 128-135 (.ts) : modifier un client : le formulaire reçoit sa fiche. La version lue avec la fiche repart avec la modification : si quelqu'un a modifié la fiche entre-temps, le serveur répondra 409 [CONCURRENT_UPDATE] (le verrou optimiste) ;
  • lignes 142-170 (.ts) : l'enregistrement : [POST /api/clients] (création) ou [PUT /api/clients/:id] (modification), avec les valeurs du formulaire et la version (ligne 144). En cas de succès, le formulaire est vidé, et le message de succès affiché (lignes 151-156). En cas de refus, l'[ErreurApi] donne le message général (ligne 160) et l'erreur de chaque champ (lignes 164-168) :
  • [controle.setErrors({ serveur: cle })] donne une erreur au contrôle du champ : il devient invalide (classe [ng-invalid]) ;
  • [controle.markAsTouched()] le marque « touché » (classe [ng-touched]), comme s'il avait été quitté ;
  • [Ionic] recopie ces deux classes en [ion-invalid] et [ion-touched] sur le champ (exemple 07) : il affiche alors, sous le champ, le texte de son [errorText], et le colore en rouge ;
  • et modifier le champ efface son erreur, sans une ligne de code : à chaque frappe, [Angular] revalide le contrôle avec ses règles... et il n'en a aucune. L'erreur posée à la main disparaît. C'était un [onChanged] à écrire pour chaque champ, dans le client [Flutter] ;
  • lignes 172-183 (.ts) : la suppression, après confirmation (le message de confirmation est traduit, avec le nom du client en paramètre) ;
  • lignes 11-25 (html) : la langue : un bouton dans la barre du haut, qui ouvre un [<ion-popover>] (une fenêtre surgissante, attachée au bouton dont l'[id] est donné par [trigger]) ; [dismissOnSelect] la ferme au premier choix. C'était le [PopupMenuButton] de [Flutter] ;
  • lignes 32-36 (html) : un utilisateur connecté qui n'est pas l'ADMIN : un avertissement ;
  • lignes 38-56 (html) : la liste des clients, avec leurs boutons « Modifier » et « Supprimer » ;
  • lignes 64-81 (html) : le formulaire. Chaque champ est relié à son contrôle ([formControlName]) ; ses libellés sont traduits ; [[errorText]] reçoit l'erreur du serveur, traduite (lignes 67-68 : [<ion-select>] a, lui aussi, un [errorText]). Le formulaire n'a pas de [(ngSubmit)] : le bouton « Enregistrer » appelle la commande (lignes 83-85) ;
  • lignes 90-99 (html) : le message de succès, et le message général d'erreur, traduits ; pour comprendre, le statut HTTP et la clé, en petit.

Un formulaire vide ; un homonyme (« Alice DURAND ») ; le même, en anglais :

Changer de langue retraduit tout l'écran, y compris les erreurs déjà affichées : l'écran garde les clés envoyées par le serveur, et non des textes, et les traduit à chaque affichage. Essayez aussi : un titre « Dr », un login déjà pris ([martin]), un login invalide (« Jean M »), la même fiche modifiée sur deux appareils (ou dans deux onglets du navigateur, ou dans l'application et avec [curl]).

Le principe est celui de tous les formulaires de l'étude de cas : les règles vivent en UN seul endroit, le serveur ; l'application affiche ses réponses, dans la langue de l'utilisateur. Rien n'interdit, bien sûr, de vérifier aussi les champs sur le téléphone (exemple 08), pour répondre plus vite à l'utilisateur ; mais le serveur doit tout revérifier, et son verdict a le dernier mot.

En comparant les deux clients, on mesure ce qu'[Ionic] doit au navigateur : le client HTTP rend directement des objets [JavaScript] (le JSON est la langue maternelle de [JavaScript]), le cookie du jeton circule sans une ligne de code, et une requête se laisse annuler. En contrepartie, [TypeScript] ne vérifie pas, à l'exécution, la forme des réponses, ce que le filtrage de [Dart] faisait naturellement. Le client [Angular] de [RdvMedecins] (cours [Angular]) est écrit exactement comme ces exemples : le même [HttpClient], les mêmes services, les mêmes formulaires réactifs ; seuls les composants d'affichage diffèrent, ceux d'[Ionic] imitant ceux d'un téléphone [Android].