8. Dialoguer avec le serveur
Les exemples de ce chapitre interrogent le serveur JSON [RdvMedecins] : il doit être lancé ([npm run start:dev] dans le dossier [rdvmedecins-nestjs-json]), sur le port 8080, en même temps que le serveur de développement d'[Angular], sur le port 4200.
8.1. Exemple 22 : une première requête HTTP
8.1.1. Le proxy du serveur de développement
La page de l'exemple vient de http://localhost:4200 (le serveur de développement d'[Angular], lancé par [ng serve]) ; l'API est sur http://localhost:8080. Pour un navigateur, ce sont deux origines différentes (le port fait partie de l'origine). Or, par sécurité, un navigateur n'autorise pas une page à lire les réponses d'une autre origine, sauf accord explicite du serveur (le mécanisme dit CORS). Et une requête vers une autre origine n'emporte pas les cookies (donc pas le jeton), sauf si le code le demande ([withCredentials: true]) ET que le serveur l'accepte, toujours par CORS.
Plutôt que d'ouvrir le serveur aux autres origines, on fait passer les requêtes de l'API par le serveur de développement d'[Angular]. C'est le proxy déclaré dans le fichier [proxy.conf.json] de l'espace de travail :
| {
"/api": {
"target": "http://localhost:8080",
"secure": false
}
}
|
- ligne 2 : les requêtes dont le chemin commence par /api...
- ligne 3 : ... sont transmises au serveur JSON.
Le fichier est désigné, pour chaque exemple, dans les options de la commande [ng serve] du fichier [angular.json] :
| "serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
|
Le navigateur envoie [GET http://localhost:4200/api/captcha] ; le serveur de développement transmet la requête à [http://localhost:8080/api/captcha], puis renvoie la réponse au navigateur. Pour le navigateur, la page et l'API ont la même origine :

En production, il n'y aura plus de serveur de développement : c'est le serveur JSON lui-même qui servira les fichiers du client compilé (cf. chapitre « Déploiement »). Page et API auront encore la même origine.
8.1.2. La requête
[Angular] fournit son propre client HTTP, le service [HttpClient]. Il est disponible d'office dans toute application : il suffit de l'injecter.
| // =========================================================================
// 22-httpclient/src/app/app.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 d'office à toute
// l'application). http.get<T>(url) renvoie un OBSERVABLE (cf. exemple
// 19) : la requête ne part qu'au moment où l'on s'y ABONNE (subscribe) ;
// la réponse JSON arrive déjà convertie en objet, du type T annoncé ;
// - l'adresse est RELATIVE (/api/captcha) : la requête part vers le
// serveur de la page, celui d'Angular (port 4200), qui la transmet au
// serveur JSON (port 8080) grâce à son proxy (cf. proxy.conf.json) ;
// - trois états à afficher : en cours, réussi, en erreur.
// =========================================================================
import { Component, computed, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
// ce que renvoie GET /api/captcha
interface Captcha {
id: string;
svg: string;
}
@Component({
selector: 'app-root',
templateUrl: './app.html',
styles: `
.captcha {
display: inline-block;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
`,
})
export class App {
private readonly http = inject(HttpClient);
private readonly sanitizer = inject(DomSanitizer);
protected readonly captcha = signal<Captcha | null>(null);
protected readonly enCours = signal(false);
protected readonly erreur = signal<string | null>(null);
protected readonly nbRequetes = signal(0);
// [innerHTML] insère une chaîne comme du HTML. Par sécurité, Angular
// NETTOIE ce HTML ("sanitization") : il en retire les scripts... et les
// balises <svg>. bypassSecurityTrustHtml dit à Angular : « ce HTML est
// sûr, ne le nettoie pas ». À n'utiliser QUE pour un contenu sûr : ce SVG
// est fabriqué par NOTRE serveur. Avec une saisie d'utilisateur, ce serait
// ouvrir la porte à l'injection de scripts ({{ }} échappe, lui, tout).
protected readonly svg = computed<SafeHtml | null>(() => {
const c = this.captcha();
return c ? this.sanitizer.bypassSecurityTrustHtml(c.svg) : null;
});
constructor() {
this.charger(); // au démarrage, on charge un captcha
}
protected charger(): void {
this.enCours.set(true);
this.erreur.set(null);
this.http.get<Captcha>('/api/captcha').subscribe({
// next : la réponse (code 2xx), déjà convertie depuis le JSON
next: (captcha) => {
this.captcha.set(captcha);
this.nbRequetes.update((n) => n + 1);
this.enCours.set(false);
},
// error : une réponse 4xx / 5xx, ou pas de réponse du tout (status 0)
error: (e: HttpErrorResponse) => {
this.erreur.set(`le serveur a répondu ${e.status} ${e.statusText}`);
this.captcha.set(null);
this.enCours.set(false);
},
});
}
}
|
- ligne 22 : la forme de la réponse de [GET /api/captcha] ;
- ligne 39 : on injecte le client HTTP ;
- lignes 42-45 : les trois états d'une requête : en cours, réussie (le captcha), en erreur ;
- ligne 65 : [http.get<Captcha>(url)] prépare une requête GET. Elle renvoie un observable (exemple 19) : la requête ne part qu'au moment où l'on s'y abonne ([subscribe]). L'adresse est relative : la requête part vers le serveur de la page, qui la transmet au serveur JSON ;
- lignes 67-71 : [next] reçoit la réponse, déjà convertie depuis le JSON. [<Captcha>] affirme à [TypeScript] la forme de l'objet : le compilateur ne peut pas vérifier ce qu'enverra le serveur ;
- lignes 73-77 : [error] reçoit une [HttpErrorResponse], pour toute réponse 4xx ou 5xx, et aussi quand aucune réponse n'arrive (serveur arrêté, réseau coupé : [status] vaut alors 0). Si le serveur JSON est arrêté, c'est le proxy qui répond, par une erreur 5xx ;
- ligne 59 : au démarrage, on charge un captcha ;
- lignes 53-56 : l'image SVG du captcha est une chaîne de caractères, qu'il faut insérer comme du HTML ([[innerHTML]] dans le template). Par sécurité, [Angular] nettoie tout HTML inséré ainsi : il en retire les scripts... et les balises [<svg>]. [bypassSecurityTrustHtml] lui indique que ce HTML est sûr. On ne l'utilise QUE pour un contenu sûr : ce SVG est fabriqué par NOTRE serveur. Avec une donnée saisie par un utilisateur, ce serait permettre l'injection de scripts dans la page ; les doubles accolades [{{ }}], elles, échappent tout.
| <div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 22 - une première requête HTTP</h1>
<div class="card p-3">
@if (enCours()) {
<p class="text-muted">Chargement...</p>
} @else if (erreur()) {
<div class="alert alert-danger mb-3">
Impossible d'obtenir un captcha : {{ erreur() }}.<br>
Le serveur JSON RdvMedecins est-il lancé (port 8080) ?
</div>
} @else if (captcha(); as c) {
<div class="mb-2"><div class="captcha" [innerHTML]="svg()"></div></div>
<p class="small text-muted">id : <code>{{ c.id }}</code></p>
}
<button class="btn btn-primary align-self-start" [disabled]="enCours()" (click)="charger()">Autre image</button>
<p class="small text-muted mt-2 mb-0">{{ nbRequetes() }} requête(s) réussie(s) - ouvrez l'onglet Réseau des outils de développement (F12).</p>
</div>
</div>
|
- lignes 4-14 : un affichage par état ;
- ligne 12 : l'image du captcha.
[npm start 22-httpclient] ; on clique sur [Autre image] :

Dans les outils de développement du navigateur (F12), l'onglet Réseau montre la requête [captcha], envoyée à [localhost:4200], et sa réponse JSON.
8.2. Exemple 23 : connexion, cookie et session
| // =========================================================================
// 23-connexion/src/app/app.ts - se connecter au serveur RdvMedecins
// -------------------------------------------------------------------------
// 1. au démarrage : GET /api/auth/moi - sommes-nous déjà connectés ?
// (après un F5, le client a tout oublié ; le serveur, lui, lit le cookie) ;
// 2. connexion : POST /api/auth/login, corps JSON { login, password,
// captchaId, captcha }. En cas de succès, le serveur dépose le cookie
// httpOnly du jeton JWT : notre code ne le voit JAMAIS (essayez
// document.cookie dans la console : il n'y est pas) ;
// 3. requête protégée : GET /api/medecins. Le navigateur y joint le cookie
// tout seul (même origine) ;
// 4. déconnexion : POST /api/auth/logout - le serveur efface le cookie.
//
// firstValueFrom(observable) : une PROMESSE de la première valeur du flux.
// On peut alors écrire les enchaînements de requêtes avec async / await,
// plus lisibles qu'une cascade de subscribe. Une réponse d'erreur rejette
// la promesse avec une HttpErrorResponse : un simple try / catch.
//
// Un cookie appartient à une machine (localhost) et à un chemin (/api) : il
// est donc commun à TOUS les exemples. Une fois connecté ici, les exemples
// 24 et 25 le sont aussi.
// =========================================================================
import { Component, computed, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { DomSanitizer } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
interface Utilisateur {
login: string;
nom: string;
role: 'ADMIN' | 'DOCTOR' | 'USER';
}
interface Medecin {
id: number;
titre: string;
prenom: string;
nom: string;
}
interface Captcha {
id: string;
svg: string;
}
// la forme de TOUTES les erreurs de l'API
interface ErreurApi {
statusCode: number;
cle: string;
params?: Record<string, string>;
}
// les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera ngx-translate)
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.',
};
@Component({
selector: 'app-root',
imports: [FormsModule],
templateUrl: './app.html',
})
export class App {
private readonly http = inject(HttpClient);
private readonly sanitizer = inject(DomSanitizer);
protected readonly utilisateur = signal<Utilisateur | null>(null);
protected readonly medecins = signal<Medecin[]>([]);
protected readonly captcha = signal<Captcha | null>(null);
protected readonly svg = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.captcha()?.svg ?? ''));
protected readonly login = signal('admin');
protected readonly password = signal('');
protected readonly reponseCaptcha = signal('');
protected readonly erreur = signal<string | null>(null);
protected readonly pret = signal(false); // la vérification de départ est-elle terminée ?
constructor() {
this.verifierSession().catch(() => {
this.erreur.set('Le serveur JSON RdvMedecins est-il lancé (port 8080) ?');
this.pret.set(true);
});
}
protected async chargerCaptcha(): Promise<void> {
this.captcha.set(await firstValueFrom(this.http.get<Captcha>('/api/captcha')));
this.reponseCaptcha.set('');
}
// 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
private async verifierSession(): Promise<void> {
try {
this.utilisateur.set(await firstValueFrom(this.http.get<Utilisateur>('/api/auth/moi')));
await this.chargerMedecins();
} catch (e) {
if (!(e instanceof HttpErrorResponse) || e.status !== 401) {
throw e; // pas un 401 : le serveur ne répond pas
}
await this.chargerCaptcha();
}
this.pret.set(true);
}
// 2. la connexion ; post(url, corps) : le corps est envoyé en JSON
protected async seConnecter(): Promise<void> {
this.erreur.set(null);
try {
const u = await firstValueFrom(
this.http.post<Utilisateur>('/api/auth/login', {
login: this.login(),
password: this.password(),
captchaId: this.captcha()?.id,
captcha: this.reponseCaptcha(),
}),
);
this.utilisateur.set(u);
this.password.set('');
await this.chargerMedecins();
} catch (e) {
// e.error : le corps JSON de la réponse d'erreur
const erreurApi = (e as HttpErrorResponse).error as ErreurApi | null;
const cle = erreurApi?.cle ?? 'ERRORS.SERVER_UNREACHABLE';
this.erreur.set(`${(e as HttpErrorResponse).status} - ${MESSAGES[cle] ?? cle}`);
await this.chargerCaptcha(); // chaque image ne sert qu'une fois
}
}
// 3. une requête protégée
private async chargerMedecins(): Promise<void> {
this.medecins.set(await firstValueFrom(this.http.get<Medecin[]>('/api/medecins')));
}
// 4. la déconnexion
protected async seDeconnecter(): Promise<void> {
await firstValueFrom(this.http.post('/api/auth/logout', {}));
this.utilisateur.set(null);
this.medecins.set([]);
await this.chargerCaptcha();
}
}
|
- lignes 30-50 : les formes des objets échangés avec le serveur, dont celle de toutes les erreurs de l'API (lignes 46-50) ;
- lignes 53-57 : les quelques messages dont cet exemple a besoin, en français ; l'exemple 25 utilisera [ngx-translate] ;
- ligne 28 : [firstValueFrom(observable)] rend une promesse de la première valeur du flux. Une requête HTTP ne produit qu'une valeur, la réponse : on peut alors écrire les enchaînements de requêtes avec [async] / [await], plus lisibles qu'une cascade de [subscribe]. Une réponse d'erreur rejette la promesse : un simple [try / catch] l'attrape ;
- lignes 91-102 : au démarrage, [GET /api/auth/moi] : sommes-nous déjà connectés ? Après un rechargement de la page (F5), l'application [Angular] a tout oublié ; le serveur, lui, lit le cookie. 200 : l'utilisateur ; 401 : personne, on affiche alors le formulaire de connexion et son captcha (ligne 99) ;
- ligne 96 : toute autre erreur signifie que le serveur ne répond pas : elle est transmise au constructeur (lignes 79-82) ;
- lignes 105-126 : la connexion :
- ligne 109 : [http.post(url, corps)] : l'objet [corps] est envoyé en JSON, avec l'entête [Content-Type: application/json] ;
- ligne 116 : en cas de succès, le serveur a déposé le cookie du jeton. Notre code ne le voit JAMAIS : tapez [document.cookie] dans la console du navigateur, il n'y est pas ([HttpOnly]) ;
- ligne 121 : en cas d'échec, [error] contient le corps JSON de la réponse, qui dit pourquoi. On affiche le message (ligne 123) et l'on demande une nouvelle image (ligne 124) : chacune ne sert qu'une fois ;
- lignes 129-131 : une requête protégée. On n'a RIEN à faire pour s'authentifier : le navigateur joint le cookie tout seul, parce que la requête va vers la même origine que la page ;
- lignes 134-139 : la déconnexion : le serveur efface le cookie. Le corps [{}] est là pour que la requête soit un POST JSON.
Un cookie appartient à une machine (localhost) et à un chemin (/api) : il est donc commun à TOUS les exemples. Une fois connecté ici, les exemples 24 et 25 le sont aussi.
Le formulaire de connexion :
| <div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 23 - connexion au serveur</h1>
@if (!pret()) {
<p class="text-muted">Vérification de la session...</p>
} @else if (utilisateur(); as u) {
<!-- connecté -->
<div class="card p-3">
<p>Connecté : <strong>{{ u.nom }}</strong> ({{ u.login }}), rôle <span class="badge text-bg-primary">{{ u.role }}</span></p>
<h2 class="h6">GET /api/medecins</h2>
<ul>
@for (m of medecins(); track m.id) {
<li>{{ m.titre }} {{ m.prenom }} {{ m.nom }}</li>
}
</ul>
<p class="small text-muted">Rechargez la page (F5) : vous restez connecté.</p>
<button class="btn btn-outline-secondary align-self-start" (click)="seDeconnecter()">Se déconnecter</button>
</div>
} @else {
<!-- pas connecté : le formulaire -->
<form class="card p-3" (ngSubmit)="seConnecter()">
<div class="mb-2">
<label class="form-label" for="login">Login</label>
<input id="login" name="login" class="form-control" autocomplete="username" [(ngModel)]="login">
</div>
<div class="mb-2">
<label class="form-label" for="password">Mot de passe</label>
<input id="password" name="password" type="password" class="form-control" autocomplete="current-password"
[(ngModel)]="password">
</div>
<div class="mb-3">
<label class="form-label" for="captcha">Recopiez les caractères de l'image</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div [innerHTML]="svg()"></div>
<a href="#" class="small" (click)="$event.preventDefault(); chargerCaptcha()">Autre image</a>
</div>
<input id="captcha" name="captcha" class="form-control" autocomplete="off" [(ngModel)]="reponseCaptcha">
</div>
<button class="btn btn-primary">Se connecter</button>
<p class="small text-muted mt-2 mb-0">Comptes : admin, pelissier, martin... (mot de passe = login).</p>
</form>
}
@if (erreur()) {
<div class="alert alert-danger mt-3">{{ erreur() }}</div>
}
</div>
|
- ligne 3 : tant que la vérification de départ n'est pas terminée, on ne sait pas quoi afficher ;
- lignes 5-17 : connecté : l'utilisateur et la liste des médecins ;
- lignes 20-40 : pas connecté : le formulaire, avec [ngModel] (exemple 05) ;
- ligne 34 : [$event.preventDefault()] empêche le lien de suivre son adresse (#).
[npm start 23-connexion]. Avec un mauvais mot de passe (le serveur est en mode test : n'importe quel captcha accepte la réponse [test]) :

Avec le bon mot de passe, puis un rechargement de la page (F5) : on reste connecté.

8.3. Exemple 24 : une couche d'accès à l'API
Dans l'exemple 23, chaque requête répétait les mêmes gestes : le préfixe /api, [firstValueFrom], la lecture du corps d'erreur, le choix d'un message. Cet exemple les regroupe dans quelques fichiers, placés dans le dossier [commun] de l'espace de travail, parce que l'exemple 25 les utilise aussi :
| exemples-angular/commun/
├── types.ts les objets JSON de l'API
├── erreur-api.ts une classe pour toutes les erreurs de l'API
├── erreurs.interceptor.ts un intercepteur HTTP
├── api.service.ts des requêtes en une ligne
└── barre-connexion.ts la connexion, en une barre
|
Le fichier [tsconfig.app.json] de l'exemple ajoute ce dossier aux fichiers à compiler :
| {
"extends": "../tsconfig.json",
"include": ["src/**/*.ts", "../commun/**/*.ts"]
}
|
8.3.1. Les types
Les types des objets JSON de l'API, décrits au chapitre précédent :
| // =========================================================================
// 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)
// =========================================================================
export type Role = 'ADMIN' | 'DOCTOR' | 'USER';
// POST /api/auth/login, GET /api/auth/moi
export interface Utilisateur {
login: string;
nom: string;
role: Role;
}
// GET /api/captcha
export interface Captcha {
id: string;
svg: string;
}
// un médecin ou un client (GET /api/medecins, GET /api/clients...)
export interface Personne {
id: number;
version: number;
titre: string;
prenom: string;
nom: string;
login?: string; // seulement pour l'ADMIN
}
export interface Identite {
titre: string;
prenom: string;
nom: string;
}
// une ligne de GET /api/agenda
export interface CreneauAgenda {
id: number;
hdebut: number;
mdebut: number;
hfin: number;
mfin: number;
libre: boolean;
patient: Identite | null;
moi: boolean;
reservable: boolean;
idRv: number | null;
}
// GET /api/agenda?idMedecin=...&jour=...
export interface Agenda {
medecin: Personne;
jour: string;
creneaux: CreneauAgenda[];
info: string | null;
}
|
8.3.2. Les erreurs
| // =========================================================================
// 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": {...} }
// HttpClient les signale par une HttpErrorResponse, dont la propriété error
// contient ce corps JSON... ou autre chose : null (pas de réponse), du texte
// (une page d'erreur du proxy quand le serveur JSON est arrêté). On
// convertit tout cela 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';
export class ErreurApi extends Error {
constructor(
public readonly statusCode: number, // 0 : le serveur n'a pas répondu
public readonly cle: string, // ex : 'ERRORS.LOGIN_TAKEN'
public readonly params: Record<string, string> = {},
public readonly champs: Record<string, string> = {},
) {
super(cle);
this.name = 'ErreurApi';
}
}
// une erreur quelconque -> une ErreurApi
export function versErreurApi(e: unknown): ErreurApi {
if (e instanceof ErreurApi) {
return e;
}
if (!(e instanceof HttpErrorResponse)) {
return new ErreurApi(0, 'ERRORS.SERVER_ERROR');
}
// le corps JSON de l'erreur, s'il existe et a la bonne forme
const corps = (typeof e.error === 'object' && e.error !== null ? e.error : {}) as {
cle?: string;
params?: Record<string, string>;
champs?: Record<string, string>;
};
// pas de clé : ce n'est pas notre serveur qui a répondu (proxy, réseau)
const cle = corps.cle ?? (e.status === 0 || e.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR');
return new ErreurApi(e.status, cle, corps.params, corps.champs);
}
|
- lignes 16-26 : une classe d'erreur qui porte ce que renvoie le serveur : le code HTTP, la clé du message, ses paramètres, l'erreur de chaque champ. Le code 0 signifie « pas de réponse » ;
- lignes 29-45 : la conversion d'une erreur quelconque en [ErreurApi] :
- ligne 37 : le corps de la réponse n'est utilisé que s'il est un objet. Quand le serveur JSON est arrêté, c'est le proxy qui répond, avec du texte ;
- ligne 43 : sans clé, ce n'est pas notre serveur qui a répondu.
8.3.3. Un intercepteur
Un intercepteur est une fonction par laquelle passent TOUTES les requêtes de [HttpClient], et leurs réponses. Il peut modifier la requête avant son envoi (ajouter un entête, par exemple), ou la réponse à son retour.
| // =========================================================================
// commun/erreurs.interceptor.ts - un INTERCEPTEUR HTTP
// -------------------------------------------------------------------------
// Un intercepteur est une fonction par laquelle passent TOUTES les requêtes
// de HttpClient (et leurs réponses). Il reçoit la requête (req) et la suite
// de la chaîne (next) :
// - next(req) envoie la requête et renvoie l'observable de la réponse ;
// - on peut modifier la requête avant (req.clone({...})), ou la réponse
// après (opérateurs RxJS sur l'observable).
// Celui-ci convertit toute erreur en ErreurApi (catchError) : le reste de
// l'application n'a plus jamais affaire aux HttpErrorResponse.
// Il est déclaré dans la configuration de l'application :
// provideHttpClient(withInterceptors([erreursInterceptor]))
// =========================================================================
import { HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { versErreurApi } from './erreur-api';
export const erreursInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(catchError((e: unknown) => throwError(() => versErreurApi(e))));
|
- ligne 20 : un intercepteur reçoit la requête ([req]) et la suite de la chaîne ([next]). [next(req)] envoie la requête et rend l'observable de la réponse ;
- ligne 21 : l'opérateur [catchError] remplace toute erreur du flux par une [ErreurApi]. Le reste de l'application n'a plus jamais affaire aux [HttpErrorResponse].
8.3.4. Le service ApiService
| // =========================================================================
// commun/api.service.ts - des requêtes vers l'API, en une ligne
// -------------------------------------------------------------------------
// Dans l'exemple 23, chaque requête répétait les mêmes gestes : préfixe
// /api, firstValueFrom, lecture du corps d'erreur... On les regroupe ici :
//
// const medecins = await this.api.get<Personne[]>('/medecins');
// await this.api.post('/rendez-vous', { jour, idCreneau, idClient });
//
// - en cas de succès, la promesse renvoie le corps JSON, TYPÉ ;
// - en cas d'échec, elle est REJETÉE avec une ErreurApi (grâce à
// l'intercepteur erreurs.interceptor.ts).
// Utilisé par les exemples 24 et 25 ; l'étude de cas en reprend le principe.
// =========================================================================
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient);
get<T>(url: string): Promise<T> {
return firstValueFrom(this.http.get<T>(`/api${url}`));
}
// un POST a toujours un corps JSON (le serveur refuse un POST qui n'en est pas)
post<T>(url: string, corps: unknown = {}): Promise<T> {
return firstValueFrom(this.http.post<T>(`/api${url}`, corps));
}
put<T>(url: string, corps: unknown): Promise<T> {
return firstValueFrom(this.http.put<T>(`/api${url}`, corps));
}
delete<T>(url: string, corps?: unknown): Promise<T> {
return firstValueFrom(this.http.delete<T>(`/api${url}`, { body: corps }));
}
}
|
- lignes 24-39 : une méthode par verbe HTTP. Chacune ajoute le préfixe /api, et rend une promesse du corps de la réponse, typée : [api.get<Personne[]>('/medecins')] rend une [Promise<Personne[]>]. En cas d'échec, la promesse est rejetée avec une [ErreurApi] ;
- ligne 29 : [post] envoie par défaut le corps [{}] : le serveur refuse un POST qui n'est pas en JSON.
8.3.5. La couche d'accès à l'API
| // =========================================================================
// 24-service-api/src/app/rdvmedecins.service.ts - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une méthode par route de l'API, typée. Les composants ne connaissent ni
// les URL, ni HttpClient, ni le format JSON : ils appellent des méthodes.
// C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API
// change (une URL, un nom de champ), on ne modifie que ce fichier.
// =========================================================================
import { inject, Injectable } from '@angular/core';
import { HttpResourceRequest } from '@angular/common/http';
import { ApiService } from '../../../commun/api.service';
import { Personne } from '../../../commun/types';
@Injectable({ providedIn: 'root' })
export class RdvMedecinsService {
private readonly api = inject(ApiService);
getMedecins(): Promise<Personne[]> {
return this.api.get<Personne[]>('/medecins');
}
getClients(): Promise<Personne[]> {
return this.api.get<Personne[]>('/clients');
}
// la DESCRIPTION de la requête de l'agenda, pour httpResource (cf. app.ts) ;
// params fabrique "?idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
requeteAgenda(idMedecin: number, jour: string): HttpResourceRequest {
return { url: '/api/agenda', params: { idMedecin, jour } };
}
reserver(jour: string, idCreneau: number, idClient?: number): Promise<{ id: number }> {
return this.api.post<{ id: number }>('/rendez-vous', { jour, idCreneau, idClient });
}
annuler(idRv: number): Promise<Record<string, never>> {
return this.api.delete<Record<string, never>>(`/rendez-vous/${idRv}`);
}
}
|
Une méthode par route de l'API. Les composants ne connaissent plus ni les adresses, ni [HttpClient], ni le format JSON : ils appellent des méthodes. C'est l'équivalent, côté client, de la couche d'accès aux données côté serveur : si l'API change, on ne modifie que ce fichier.
- lignes 29-31 : la description de la requête de l'agenda, et non la requête elle-même : elle sera utilisée par [httpResource] (voir plus loin). [params] fabrique la chaîne [?idMedecin=1&jour=2030-01-15], en encodant les caractères qui doivent l'être.
8.3.6. La barre de connexion
Le formulaire de l'exemple 23, réduit à une barre et réécrit avec [ApiService] :
| // =========================================================================
// commun/barre-connexion.ts - connexion / déconnexion, en une barre
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 23, réduit à une barre et réécrit avec
// ApiService. Le composant émet 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.
//
// ViewEncapsulation.None : les styles du composant s'appliquent à TOUTE la
// page. Nécessaire ici : le <svg> du captcha, inséré par [innerHTML], ne
// porte pas les attributs que le style "encapsulé" d'Angular exige.
// =========================================================================
import { Component, computed, inject, output, signal, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { ApiService } from './api.service';
import { ErreurApi } from './erreur-api';
import { Captcha, Utilisateur } from './types';
@Component({
selector: 'app-barre-connexion',
imports: [FormsModule],
encapsulation: ViewEncapsulation.None,
styles: `
.barre-captcha svg {
display: block;
height: 32px;
width: auto;
}
`,
template: `
<div class="alert alert-secondary d-flex flex-wrap align-items-center gap-2 py-2">
@if (utilisateur(); as u) {
<span>Connecté : <strong>{{ u.nom }}</strong> <span class="badge text-bg-primary ms-1">{{ u.role }}</span></span>
<button class="btn btn-sm btn-outline-secondary ms-auto" (click)="seDeconnecter()">Se déconnecter</button>
} @else {
<form class="d-flex flex-wrap align-items-center gap-2 w-100" (ngSubmit)="seConnecter()">
<input name="login" class="form-control form-control-sm w-auto" placeholder="login" autocomplete="username"
[(ngModel)]="login">
<input name="password" type="password" class="form-control form-control-sm w-auto" placeholder="mot de passe"
autocomplete="current-password" [(ngModel)]="password">
<div class="barre-captcha" [innerHTML]="svg()"></div>
<input name="captcha" class="form-control form-control-sm w-auto" placeholder="captcha" autocomplete="off" size="8"
[(ngModel)]="reponseCaptcha">
<button class="btn btn-sm btn-primary">Se connecter</button>
@if (erreur()) {
<span class="text-danger small">{{ erreur() }}</span>
}
</form>
}
</div>
`,
})
export class BarreConnexion {
readonly utilisateurChange = output<Utilisateur | null>();
private readonly api = inject(ApiService);
private readonly sanitizer = inject(DomSanitizer);
protected readonly utilisateur = signal<Utilisateur | null>(null);
private readonly captcha = signal<Captcha | null>(null);
protected readonly svg = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.captcha()?.svg ?? ''));
protected readonly login = signal('admin');
protected readonly password = signal('');
protected readonly reponseCaptcha = signal('');
protected readonly erreur = signal<string | null>(null);
constructor() {
this.demarrer();
}
private async demarrer(): Promise<void> {
try {
this.changer(await this.api.get<Utilisateur>('/auth/moi'));
} catch {
this.changer(null); // 401 : personne n'est connecté
await this.nouveauCaptcha().catch(() => this.erreur.set('Le serveur JSON est-il lancé (port 8080) ?'));
}
}
private async nouveauCaptcha(): Promise<void> {
this.captcha.set(await this.api.get<Captcha>('/captcha'));
this.reponseCaptcha.set('');
}
private changer(u: Utilisateur | null): void {
this.utilisateur.set(u);
this.utilisateurChange.emit(u);
}
protected async seConnecter(): Promise<void> {
this.erreur.set(null);
try {
const u = await this.api.post<Utilisateur>('/auth/login', {
login: this.login(),
password: this.password(),
captchaId: this.captcha()?.id,
captcha: this.reponseCaptcha(),
});
this.password.set('');
this.changer(u);
} catch (e) {
this.erreur.set(e instanceof ErreurApi ? `${e.statusCode} ${e.cle}` : String(e));
await this.nouveauCaptcha();
}
}
protected async seDeconnecter(): Promise<void> {
await this.api.post('/auth/logout');
this.changer(null);
await this.nouveauCaptcha();
}
}
|
- ligne 56 : le composant émet [utilisateurChange] avec l'utilisateur connecté (ou [null]) chaque fois qu'il change, y compris au démarrage ;
- ligne 74 : avec [ApiService], une requête tient en une ligne ; en cas d'échec (401), on passe dans le [catch] ;
- ligne 103 : [instanceof] distingue une erreur de l'API d'une autre anomalie ;
- ligne 24 : [ViewEncapsulation.None] : les styles du composant s'appliquent à toute la page. Par défaut, [Angular] limite les styles d'un composant à son template, en ajoutant à ses éléments des attributs particuliers. Le [<svg>] inséré par [[innerHTML]] n'a pas ces attributs : sans cette option, la règle des lignes 26-30 ne l'atteindrait pas.
8.3.7. La configuration
| // =========================================================================
// 24-service-api/src/app/app.config.ts
// -------------------------------------------------------------------------
// provideHttpClient(withInterceptors([...])) : HttpClient est fourni d'office
// à toute application ; on ne le déclare que pour le CONFIGURER, ici en lui
// ajoutant l'intercepteur qui convertit les erreurs en ErreurApi.
// =========================================================================
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { erreursInterceptor } from '../../../commun/erreurs.interceptor';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([erreursInterceptor]))],
};
|
- ligne 14 : [HttpClient] est fourni d'office ; on ne le déclare que pour le configurer, ici en lui ajoutant l'intercepteur.
8.3.8. La page
| // =========================================================================
// 24-service-api/src/app/app.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
// un rechargement de l'agenda : c'est le SERVEUR qui fait foi.
//
// httpResource : une requête GET décrite par une fonction qui lit des
// signaux (ici idMedecin et jour). Angular l'envoie au départ, puis la
// RENVOIE chaque fois que l'un de ces signaux change. Le résultat est un
// objet de signaux : value() (la réponse), isLoading(), error()... et
// reload() pour la renvoyer à la demande.
// =========================================================================
import { Component, computed, effect, inject, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { BarreConnexion } from '../../../commun/barre-connexion';
import { ErreurApi } from '../../../commun/erreur-api';
import { Agenda, CreneauAgenda, Personne, Utilisateur } from '../../../commun/types';
import { RdvMedecinsService } from './rdvmedecins.service';
function demain(): string {
const d = new Date(Date.now() + 24 * 3600 * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@Component({
selector: 'app-root',
imports: [FormsModule, BarreConnexion],
templateUrl: './app.html',
})
export class App {
private readonly rdv = inject(RdvMedecinsService);
protected readonly utilisateur = signal<Utilisateur | null>(null);
protected readonly medecins = signal<Personne[]>([]);
protected readonly clients = signal<Personne[]>([]);
protected readonly idMedecin = signal<number | null>(null);
protected readonly jour = signal(demain());
protected readonly idClient = signal<number | null>(null);
protected readonly message = signal<{ type: 'success' | 'danger'; texte: string } | null>(null);
protected readonly gestionnaire = computed(() => this.utilisateur() !== null && this.utilisateur()?.role !== 'USER');
// undefined : pas de requête (aucun médecin choisi, ou personne de connecté)
protected readonly agenda = httpResource<Agenda>(() => {
const id = this.idMedecin();
return id === null || this.utilisateur() === null ? undefined : this.rdv.requeteAgenda(id, this.jour());
});
// l'erreur de la dernière requête de l'agenda, sous forme d'ErreurApi
protected readonly erreurAgenda = computed(() => {
const e = this.agenda.error();
return e instanceof ErreurApi ? `${e.statusCode} ${e.cle}` : null;
});
constructor() {
// un nouveau choix de médecin ou de jour efface le message précédent
effect(() => {
this.idMedecin();
this.jour();
this.message.set(null);
});
}
protected 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)}`;
}
// une erreur de l'API devient un message (sa clé, faute de dictionnaire ici)
private signaler(e: unknown): void {
this.message.set({ type: 'danger', texte: e instanceof ErreurApi ? `${e.statusCode} ${e.cle}` : String(e) });
}
// connexion / déconnexion : on (re)charge ce que ce rôle a le droit de voir
protected async surConnexion(u: Utilisateur | null): Promise<void> {
this.utilisateur.set(u);
this.medecins.set([]);
this.clients.set([]);
if (u === null) {
return;
}
try {
this.medecins.set(await this.rdv.getMedecins());
// la liste des patients : seulement pour ADMIN et DOCTOR (un USER recevrait 403)
this.clients.set(this.gestionnaire() ? await this.rdv.getClients() : []);
} catch (e) {
this.signaler(e);
}
}
protected async reserver(c: CreneauAgenda): Promise<void> {
try {
await this.rdv.reserver(this.jour(), c.id, this.gestionnaire() ? (this.idClient() ?? undefined) : undefined);
this.message.set({ type: 'success', texte: `Créneau ${this.horaire(c)} réservé.` });
} catch (e) {
this.signaler(e);
}
this.agenda.reload();
}
protected async annuler(c: CreneauAgenda): Promise<void> {
try {
await this.rdv.annuler(c.idRv as number);
this.message.set({ type: 'success', texte: `Rendez-vous de ${this.horaire(c)} annulé.` });
} catch (e) {
this.signaler(e);
}
this.agenda.reload();
}
}
|
- ligne 45 : ADMIN ou DOCTOR ;
- lignes 48-51 : [httpResource] décrit une requête GET par une fonction qui lit des signaux (ici [idMedecin], [jour] et [utilisateur]). [Angular] envoie la requête au départ, puis la renvoie chaque fois que l'un de ces signaux change. La fonction rend [undefined] quand il n'y a pas de requête à envoyer. Le résultat est un objet de signaux : [value()] (la réponse), [isLoading()], [error()], [hasValue()]... et une méthode [reload()] pour renvoyer la requête à la demande. Si une requête est encore en cours quand les signaux changent, elle est abandonnée, comme avec [switchMap] (exemple 19) ;
- lignes 54-57 : l'erreur de la dernière requête de l'agenda. Les requêtes de [httpResource] passent elles aussi par l'intercepteur : l'erreur est une [ErreurApi] ;
- lignes 61-65 : un effet qui efface le message précédent quand le médecin ou le jour change ;
- lignes 79-93 : à chaque connexion ou déconnexion, on (re)charge ce que ce rôle a le droit de voir. Un patient recevrait 403 pour la liste des clients : on ne la demande même pas ;
- lignes 95-103 : une réservation, puis, QUOI QU'IL ARRIVE, un rechargement de l'agenda (ligne 102). C'est le serveur qui fait foi : si le créneau a été pris entre-temps par quelqu'un d'autre, l'agenda rechargé le montre ;
- ligne 97 : un gestionnaire réserve pour le patient choisi ; un patient, pour lui-même.
| <div class="container py-4">
<h1 class="h3 mb-3">Exemple 24 - une couche d'accès à l'API</h1>
<app-barre-connexion (utilisateurChange)="surConnexion($event)" />
@if (utilisateur()) {
<div class="row g-2 mb-3">
<div class="col-sm-5">
<select class="form-select" [(ngModel)]="idMedecin">
<option [ngValue]="null" disabled>-- choisir un médecin --</option>
@for (m of medecins(); track m.id) {
<option [ngValue]="m.id">{{ m.titre }} {{ m.prenom }} {{ m.nom }}</option>
}
</select>
</div>
<div class="col-sm-3"><input type="date" class="form-control" [(ngModel)]="jour"></div>
@if (gestionnaire()) {
<div class="col-sm-4">
<select class="form-select" [(ngModel)]="idClient">
<option [ngValue]="null">-- patient à inscrire --</option>
@for (c of clients(); track c.id) {
<option [ngValue]="c.id">{{ c.titre }} {{ c.prenom }} {{ c.nom }}</option>
}
</select>
</div>
}
</div>
@if (message(); as m) {
<div class="alert" [class]="'alert-' + m.type">{{ m.texte }}</div>
}
@if (erreurAgenda()) {
<div class="alert alert-danger">{{ erreurAgenda() }}</div>
}
@if (agenda.isLoading()) {
<p class="small text-muted">chargement de l'agenda...</p>
}
<!-- hasValue() : une réponse est disponible (value() n'est pas undefined) -->
@if (agenda.hasValue()) {
@let a = agenda.value();
<!-- info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir -->
@if (a.info) {
<div class="alert alert-info">{{ a.info }}</div>
}
<table class="table table-sm align-middle">
<caption class="caption-top">Agenda du {{ a.jour }} - {{ a.medecin.titre }} {{ a.medecin.prenom }} {{ a.medecin.nom }}</caption>
<tbody>
@for (c of a.creneaux; track c.id) {
<tr>
<td>{{ horaire(c) }}</td>
<td>
@if (c.libre) {
<span class="badge text-bg-success">Libre</span>
} @else if (c.patient; as p) {
<span class="badge" [class]="c.moi ? 'text-bg-primary' : 'text-bg-secondary'">{{ p.titre }} {{ p.prenom }} {{ p.nom }}</span>
} @else {
<span class="badge text-bg-secondary">Réservé</span>
}
</td>
<td class="text-end">
<!-- les boutons suivent les droits calculés par le serveur -->
@if (c.reservable) {
<button class="btn btn-sm btn-outline-success" (click)="reserver(c)">Réserver</button>
}
@if (c.idRv !== null) {
<button class="btn btn-sm btn-outline-danger" (click)="annuler(c)">Annuler</button>
}
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
|
- ligne 3 : la barre de connexion ;
- ligne 34 : [isLoading()] : une requête est en cours ;
- ligne 39 : [hasValue()] : une réponse est disponible ;
- ligne 40 : [@let] évite de répéter [agenda.value()] ;
- ligne 57 : un créneau occupé dont le serveur n'a pas envoyé le patient (vu par un patient) ;
- lignes 62-67 : les boutons suivent les droits calculés par le serveur.
[npm start 24-service-api]. L'administrateur réserve le créneau de 8 h 40 pour Alice DURAND :

Le même agenda, vu par le patient martin : le créneau est réservé, sans nom de patient.

8.4. Exemple 25 : les erreurs renvoyées par le serveur
Cet exemple est la gestion des clients, par l'administrateur. Son formulaire ne fait VOLONTAIREMENT aucune validation dans le navigateur : tout est envoyé au serveur, qui répond, en cas de refus, par une erreur JSON :
| 400 { "cle": "ERRORS.INVALID_FORM", "champs": { "nom": "ERRORS.LASTNAME_REQUIRED" } }
409 { "cle": "ERRORS.PATIENT_ALREADY_EXISTS", "champs": { "prenom": "...", "nom": "..." } }
409 { "cle": "ERRORS.CONCURRENT_UPDATE" }
|
Il faut alors afficher le message général, et placer l'erreur de chaque champ sous ce champ, dans la langue de l'utilisateur.
Les dictionnaires reprennent les clés d'erreur du serveur :
| {
"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."
},
"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."
}
}
|
| {
"FORM": {
"TITRE_AJOUT": "Add a patient",
"TITRE_MODIF": "Edit a patient",
"TITRE": "Title",
"PRENOM": "First name",
"NOM": "Last name",
"LOGIN": "Login",
"PASSWORD": "Password",
"ENREGISTRER": "Save",
"ANNULER": "Cancel",
"MODIFIER": "Edit",
"SUPPRIMER": "Delete",
"CONFIRMER_SUPPRESSION": "Delete patient {{nom}}, their account and their appointments?",
"AJOUTE": "Patient {{nom}} has been added (login: {{login}}).",
"MODIFIE": "Patient {{nom}} has been updated.",
"SUPPRIME": "Patient {{nom}} has been deleted.",
"ADMIN_SEULEMENT": "Log in with the admin account: only the ADMIN role manages patients."
},
"ERRORS": {
"INVALID_FORM": "The form contains errors.",
"INVALID_TITLE": "Choose a title from the list.",
"FIRSTNAME_REQUIRED": "First name is required.",
"FIRSTNAME_TOO_LONG": "First name must not exceed 20 characters.",
"LASTNAME_REQUIRED": "Last name is required.",
"LASTNAME_TOO_LONG": "Last name must not exceed 20 characters.",
"INVALID_LOGIN": "30 characters max: unaccented lowercase letters, digits, dot, hyphen, underscore.",
"PASSWORD_TOO_LONG": "Password must not exceed 72 characters.",
"LOGIN_TAKEN": "This login is already used by another account.",
"PATIENT_ALREADY_EXISTS": "A patient with this first and last name already exists.",
"PATIENT_NOT_FOUND": "This patient does not exist (anymore).",
"CONCURRENT_UPDATE": "This record was changed meanwhile by another user. Reload it before trying again.",
"FORBIDDEN": "Your role does not allow this action.",
"AUTH_REQUIRED": "Please log in first.",
"SESSION_EXPIRED": "Your session has expired: please log in again.",
"SERVER_UNREACHABLE": "The server does not respond.",
"SERVER_ERROR": "An unexpected error occurred."
}
}
|
| // =========================================================================
// 25-erreurs-serveur/src/app/i18n/i18n.ts - ngx-translate, comme à l'exemple 21
// =========================================================================
import { Provider } from '@angular/core';
import { provideTranslateLoader, provideTranslateService, TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import fr from './fr.json';
import en from './en.json';
class DictionnairesLoader implements TranslateLoader {
getTranslation(langue: string): Observable<TranslationObject> {
return of(langue === 'en' ? en : fr);
}
}
export function provideI18n(): Provider[] {
return provideTranslateService({ loader: provideTranslateLoader(DictionnairesLoader), fallbackLang: 'fr', lang: 'fr' });
}
|
La configuration de l'application réunit ce que les exemples précédents ont présenté :
| // =========================================================================
// 25-erreurs-serveur/src/app/app.config.ts
// -------------------------------------------------------------------------
// - HttpClient et l'intercepteur des erreurs (cf. exemple 24) ;
// - ngx-translate (cf. exemple 21) ;
// - les classes Bootstrap des champs (cf. exemple 07). Ici, pas de
// "touched" : un champ est encadré de rouge dès qu'il porte une erreur,
// c'est-à-dire dès que le SERVEUR en a signalé une.
// =========================================================================
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideSignalFormsConfig } from '@angular/forms/signals';
import { erreursInterceptor } from '../../../commun/erreurs.interceptor';
import { provideI18n } from './i18n/i18n';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([erreursInterceptor])),
provideI18n(),
provideSignalFormsConfig({ classes: { 'is-invalid': (champ) => champ.state().invalid() } }),
],
};
|
- ligne 19 : le client HTTP et son intercepteur (exemple 24) ;
- ligne 20 : [ngx-translate] (exemple 21) ;
- ligne 21 : les classes [Bootstrap] des champs (exemple 07). Ici, un champ est encadré de rouge dès qu'il porte une erreur, c'est-à-dire dès que le serveur en a signalé une.
La page :
| // =========================================================================
// 25-erreurs-serveur/src/app/app.ts - afficher les erreurs du SERVEUR
// -------------------------------------------------------------------------
// La gestion des clients (compte admin). Ce formulaire ne fait
// VOLONTAIREMENT aucune validation dans le navigateur : son schéma est vide.
// Tout est envoyé au serveur, qui répond, en cas de refus, par une erreur
// JSON (convertie en ErreurApi par l'intercepteur) :
//
// 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)
//
// submit(formulaire, action) : l'action envoie le modèle au serveur. Si elle
// renvoie des ERREURS (un tableau), les formulaires à signaux les attachent
// aux champs désignés (fieldTree) : ils apparaissent dans f.nom().errors(),
// comme les erreurs d'un validateur. Leur message est ici la CLÉ envoyée par
// le serveur, que le template traduit (ngx-translate). Essayez : un nom
// vide, un titre "Dr", un homonyme (Alice DURAND), un login déjà pris
// (martin), la même fiche modifiée dans deux onglets...
// =========================================================================
import { Component, computed, inject, signal } from '@angular/core';
import { FormField, form, submit, ValidationError } from '@angular/forms/signals';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../../../commun/api.service';
import { BarreConnexion } from '../../../commun/barre-connexion';
import { ErreurApi } from '../../../commun/erreur-api';
import { Personne, Utilisateur } from '../../../commun/types';
// la fenêtre de confirmation de l'exemple 16, réutilisée telle quelle
import { ConfirmationService } from '../../../16-confirmation/src/app/confirmation/confirmation.service';
import { FenetreConfirmation } from '../../../16-confirmation/src/app/confirmation/fenetre-confirmation';
interface SaisieClient {
titre: string;
prenom: string;
nom: string;
login: string;
password: string;
version: number;
}
const VIDE: SaisieClient = { titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: 0 };
@Component({
selector: 'app-root',
imports: [FormField, TranslatePipe, BarreConnexion, FenetreConfirmation],
templateUrl: './app.html',
})
export class App {
private readonly api = inject(ApiService);
private readonly confirmation = inject(ConfirmationService);
protected readonly translate = inject(TranslateService);
protected readonly utilisateur = signal<Utilisateur | null>(null);
protected readonly clients = signal<Personne[]>([]);
protected readonly estAdmin = computed(() => this.utilisateur()?.role === 'ADMIN');
// le formulaire : idEdite === null -> création, sinon modification
protected readonly idEdite = signal<number | null>(null);
protected readonly modele = signal<SaisieClient>({ ...VIDE });
protected readonly f = form(this.modele); // aucun validateur : c'est le serveur qui valide
// le message général de la dernière erreur, et celui d'un succès
protected readonly erreurPage = signal<ErreurApi | null>(null);
protected readonly succes = signal<{ cle: string; params: Record<string, string> } | null>(null);
protected async surConnexion(u: Utilisateur | null): Promise<void> {
this.utilisateur.set(u);
this.clients.set([]);
if (u?.role === 'ADMIN') {
await this.recharger();
}
}
private async recharger(): Promise<void> {
this.clients.set(await this.api.get<Personne[]>('/clients'));
}
protected nouveau(): void {
this.idEdite.set(null);
this.modele.set({ ...VIDE });
this.f().reset(); // l'état des champs (et leurs erreurs) repart de zéro
this.effacerMessages();
}
protected 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.modele.set({ titre: c.titre, prenom: c.prenom, nom: c.nom, login: c.login ?? '', password: '', version: c.version });
this.f().reset();
this.effacerMessages();
}
private effacerMessages(): void {
this.erreurPage.set(null);
this.succes.set(null);
}
// l'erreur de CHAQUE champ, renvoyée par le serveur -> une erreur du formulaire
private erreursDesChamps(e: ErreurApi): ValidationError.WithOptionalFieldTree[] {
const champs = Object.keys(this.modele()) as (keyof SaisieClient)[];
return Object.entries(e.champs)
.filter(([champ]) => champs.includes(champ as keyof SaisieClient))
.map(([champ, cle]) => ({ kind: 'serveur', message: cle, fieldTree: this.f[champ as keyof SaisieClient] }));
}
protected async enregistrer(evenement: SubmitEvent): Promise<void> {
evenement.preventDefault();
this.effacerMessages();
const id = this.idEdite();
await submit(this.f, async () => {
try {
const corps = this.modele();
const client =
id === null
? await this.api.post<Personne>('/clients', corps)
: await this.api.put<Personne>(`/clients/${id}`, corps);
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();
return null;
} catch (e) {
if (!(e instanceof ErreurApi)) {
throw e;
}
this.erreurPage.set(e); // le message général
return this.erreursDesChamps(e); // l'erreur de chaque champ
}
});
}
protected async supprimer(c: Personne): Promise<void> {
const nom = `${c.prenom} ${c.nom}`;
if (!(await this.confirmation.confirmer(this.translate.instant('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) {
this.erreurPage.set(e instanceof ErreurApi ? e : null);
}
await this.recharger();
}
}
|
- lignes 30-31 : la fenêtre de confirmation de l'exemple 16, réutilisée telle quelle : un composant bien fait est réutilisable ;
- ligne 61 : un formulaire à signaux (exemple 06), sans schéma : aucun validateur ;
- lignes 63-64 : le message général de la dernière erreur, et celui d'un succès ;
- lignes 85-92 : pour modifier une fiche, on recopie ses valeurs dans le formulaire, version comprise : elle repartira avec la modification (verrou optimiste). [reset()] remet à zéro l'état des champs, et leurs erreurs ;
- lignes 107-134 : l'enregistrement. [submit(formulaire, action)] exécute l'action, qui envoie le modèle au serveur : POST pour une création (ligne 116), PUT pour une modification (ligne 117). Pendant son exécution, [f().submitting()] est vrai ;
- ligne 125 : [null] : pas d'erreur ;
- lignes 130-131 : en cas de refus, on garde le message général, et l'on renvoie les erreurs des champs. Les formulaires à signaux les attachent alors aux champs désignés : elles apparaissent dans [f.nom().errors()], exactement comme les erreurs d'un validateur ; elles disparaissent dès que l'utilisateur modifie le champ ;
- lignes 100-105 : la conversion. Chaque erreur désigne son champ ([fieldTree], ligne 104) ; son message est la clé envoyée par le serveur. La ligne 103 ignore un champ inconnu du formulaire ;
- ligne 138 : [translate.instant(clé, paramètres)] : la traduction d'une clé, dans le code ;
- ligne 146 : la suppression d'un client peut, elle aussi, être refusée.
| <div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Exemple 25 - les erreurs renvoyées par le serveur</h1>
<select #langue class="form-select form-select-sm w-auto" (change)="translate.use(langue.value)">
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div>
<app-barre-connexion (utilisateurChange)="surConnexion($event)" />
@if (utilisateur() && !estAdmin()) {
<p class="alert alert-warning">{{ 'FORM.ADMIN_SEULEMENT' | translate }}</p>
}
@if (estAdmin()) {
<div class="row g-4">
<div class="col-lg-6">
<table class="table table-sm align-middle">
<tbody>
@for (c of clients(); track c.id) {
<tr>
<td>{{ c.titre }} {{ c.prenom }} {{ c.nom }}</td>
<td><code>{{ c.login }}</code></td>
<td class="text-end text-nowrap">
<button class="btn btn-sm btn-outline-primary me-1" (click)="editer(c)">{{ 'FORM.MODIFIER' | translate }}</button>
<button class="btn btn-sm btn-outline-danger" (click)="supprimer(c)">{{ 'FORM.SUPPRIMER' | translate }}</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="col-lg-6">
<form class="card p-3" novalidate (submit)="enregistrer($event)">
<h2 class="h5">{{ (idEdite() === null ? 'FORM.TITRE_AJOUT' : 'FORM.TITRE_MODIF') | translate }}</h2>
<div class="mb-2">
<label class="form-label" for="titre">{{ 'FORM.TITRE' | translate }}</label>
<!-- une option "Dr" que le serveur refusera : pour voir ERRORS.INVALID_TITLE -->
<select id="titre" class="form-select" [formField]="f.titre">
<option>Mr</option><option>Mme</option><option>Melle</option><option>Dr</option>
</select>
<!-- le message d'une erreur du serveur est une CLÉ : on la traduit -->
<div class="invalid-feedback">{{ f.titre().errors()[0]?.message ?? '' | translate }}</div>
</div>
<div class="row g-2 mb-2">
<div class="col-sm-6">
<label class="form-label" for="prenom">{{ 'FORM.PRENOM' | translate }}</label>
<input id="prenom" class="form-control" [formField]="f.prenom">
<div class="invalid-feedback">{{ f.prenom().errors()[0]?.message ?? '' | translate }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="nom">{{ 'FORM.NOM' | translate }}</label>
<input id="nom" class="form-control" [formField]="f.nom">
<div class="invalid-feedback">{{ f.nom().errors()[0]?.message ?? '' | translate }}</div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<label class="form-label" for="login">{{ 'FORM.LOGIN' | translate }}</label>
<input id="login" class="form-control" [formField]="f.login">
<div class="invalid-feedback">{{ f.login().errors()[0]?.message ?? '' | translate }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="password">{{ 'FORM.PASSWORD' | translate }}</label>
<input id="password" type="password" class="form-control" autocomplete="new-password" [formField]="f.password">
<div class="invalid-feedback">{{ f.password().errors()[0]?.message ?? '' | translate }}</div>
</div>
</div>
<div class="d-flex gap-2">
<!-- submitting() : vrai pendant l'exécution de l'action de submit() -->
<button class="btn btn-primary" [disabled]="f().submitting()">{{ 'FORM.ENREGISTRER' | translate }}</button>
<button type="button" class="btn btn-secondary" (click)="nouveau()">{{ 'FORM.ANNULER' | translate }}</button>
</div>
@if (succes(); as s) {
<div class="alert alert-success mt-3 mb-0">{{ s.cle | translate: s.params }}</div>
}
<!-- le message général, et le statut HTTP, pour comprendre -->
@if (erreurPage(); as e) {
<div class="alert alert-danger mt-3 mb-0">
{{ e.cle | translate: e.params }} <span class="small text-muted">({{ e.statusCode }} {{ e.cle }})</span>
</div>
}
</form>
</div>
</div>
}
</div>
<app-fenetre-confirmation />
|
- ligne 4 : le choix de la langue ;
- ligne 40 : un titre « Dr » que le serveur refusera, pour voir [ERRORS.INVALID_TITLE] ;
- ligne 43 et suivantes : sous chaque champ, la première erreur ; son message est une clé, que le pipe [translate] traduit. [?? ''] : pas d'erreur, pas de texte ;
- ligne 71 : le bouton est désactivé pendant l'envoi ;
- ligne 80 : le message général, dans la langue courante, avec ses paramètres, suivi du statut HTTP et de la clé, pour comprendre ;
- ligne 88 : la fenêtre de confirmation.
Changer de langue retraduit aussi les messages d'erreur déjà affichés : on n'a gardé que des clés.
[npm start 25-erreurs-serveur] ; connecté comme admin, on envoie un titre « Dr », un prénom trop long, un nom vide et un login invalide :

On repart d'un formulaire vide : un titre « Dr », le prénom « Jean », un nom vide. Le serveur refuse le titre et le nom ; on corrige alors le titre et l'on tape un nom : les erreurs de ces deux champs disparaissent aussitôt, sans nouvel envoi. Le message général reste affiché jusqu'au prochain enregistrement :

Un homonyme (Alice DURAND existe déjà) : le serveur répond 409 et désigne les champs prénom et nom. Le login « martin », déjà pris, n'est pas signalé : le serveur s'arrête à la première règle de gestion violée. On passe ensuite en anglais : les messages déjà affichés sont retraduits (la barre de connexion, écrite en français, ne l'est pas) :

Essayez aussi de modifier la même fiche dans deux onglets : le second enregistrement reçoit [ERRORS.CONCURRENT_UPDATE].