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 [Vite] des exemples, sur le port 5173.
8.1. Exemple 17 : une première requête HTTP
8.1.1. Le proxy de Vite
La page de l'exemple vient de http://localhost:5173 (le serveur de [Vite]) ; 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 [fetch] n'envoie pas les cookies (donc pas le jeton) à une autre origine, sauf si on le lui demande ([credentials: 'include']) 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 Vite. C'est le proxy déclaré dans [vite.config.ts] :
| server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8080',
},
},
|
Le navigateur envoie [GET http://localhost:5173/api/captcha] ; [Vite] 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 [Vite] : 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
| <!--
=========================================================================
17-fetch/src/App.vue - 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 ...>" }.
- fetch(url) : la fonction du navigateur qui envoie une requête HTTP.
Elle renvoie une PROMESSE : on attend la réponse avec await ;
- reponse.ok : vrai pour un code 2xx ; reponse.json() lit le corps
JSON (encore une promesse) ;
- l'adresse est RELATIVE (/api/captcha) : la requête part vers le
serveur de la page, Vite (port 5173), qui la transmet au serveur JSON
(port 8080) grâce à son proxy (cf. vite.config.ts) ;
- trois états à afficher : en cours, réussi, en erreur.
=========================================================================
-->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
// ce que renvoie GET /api/captcha
interface Captcha {
id: string;
svg: string;
}
const captcha = ref<Captcha | null>(null);
const enCours = ref(false);
const erreur = ref<string | null>(null);
const nbRequetes = ref(0);
async function charger(): Promise<void> {
enCours.value = true;
erreur.value = null;
try {
const reponse = await fetch('/api/captcha');
nbRequetes.value++;
if (!reponse.ok) {
// le serveur a répondu, mais par une erreur (4xx, 5xx) - ou c'est
// Vite qui répond 502/504 parce que le serveur JSON est arrêté
throw new Error(`le serveur a répondu ${reponse.status} ${reponse.statusText}`);
}
captcha.value = (await reponse.json()) as Captcha;
} catch (e) {
// fetch lui-même échoue quand aucune réponse n'arrive (réseau coupé...)
erreur.value = e instanceof Error ? e.message : String(e);
captcha.value = null;
} finally {
enCours.value = false;
}
}
// au premier affichage, on charge un captcha
onMounted(charger);
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 17 - une première requête HTTP</h1>
<div class="card p-3">
<p v-if="enCours" class="text-muted">Chargement...</p>
<div v-else-if="erreur" class="alert alert-danger mb-3">
Impossible d'obtenir un captcha : {{ erreur }}.<br>
Le serveur JSON RdvMedecins est-il lancé (port 8080) ?
</div>
<template v-else-if="captcha">
<!-- v-html : insère une chaîne comme du HTML (ici, l'image SVG).
À n'utiliser QUE pour un contenu sûr : ce SVG est fabriqué par
NOTRE serveur. Avec une saisie d'utilisateur, v-html ouvrirait
la porte à l'injection de scripts ({{ }} échappe, lui, tout). -->
<div class="captcha mb-2" v-html="captcha.svg"></div>
<p class="small text-muted">id : <code>{{ captcha.id }}</code></p>
</template>
<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>
</template>
<style scoped>
/* :deep(svg) : la règle atteint le <svg> inséré par v-html, que le "scoped" ignorerait sinon */
.captcha :deep(svg) {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
</style>
|
- ligne 22 : la forme de la réponse de [GET /api/captcha] ;
- lignes 27-29 : les trois états d'une requête : en cours, réussie (le captcha), en erreur ;
- ligne 36 : [fetch(url)], la fonction du navigateur qui envoie une requête HTTP. Elle renvoie une promesse de réponse ; [await] suspend la fonction [async] jusqu'à son arrivée, sans bloquer la page. L'adresse est relative : la requête part vers le serveur de la page, [Vite], qui la transmet ;
- ligne 38 : [fetch] ne signale PAS d'erreur quand le serveur répond 404 ou 500 : il a bien reçu une réponse. C'est à nous de tester [reponse.ok] (vrai pour un code 2xx). Si le serveur JSON est arrêté, c'est [Vite] qui répond, par une erreur 5xx ;
- ligne 43 : [reponse.json()] lit le corps et le convertit en objet (encore une promesse). [as Captcha] affirme à [TypeScript] la forme de l'objet : le compilateur ne peut pas vérifier ce qu'enverra le serveur ;
- ligne 44 : [fetch] lui-même échoue quand aucune réponse n'arrive (réseau coupé) ;
- ligne 48 : dans tous les cas, la requête n'est plus en cours ;
- ligne 54 : au premier affichage, on charge un captcha ;
- ligne 71 : [v-html] insère une chaîne comme du HTML (ici, l'image SVG). À n'utiliser QUE pour un contenu sûr : ce SVG est fabriqué par NOTRE serveur. Avec une donnée saisie par un utilisateur, [v-html] permettrait d'injecter des scripts dans la page ; les moustaches [{{ }}], elles, échappent tout ;
- ligne 82 : [:deep(...)] : une règle [scoped] n'atteint normalement que les éléments du template ; le [<svg>] inséré par [v-html] n'en fait pas partie.

Dans les outils de développement du navigateur (F12), l'onglet Réseau montre la requête [captcha], envoyée à [localhost:5173], et sa réponse JSON.
8.2. Exemple 18 : connexion, cookie et session
| <!--
=========================================================================
18-connexion/src/App.vue - 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.
Un cookie appartient à une machine (localhost) et à un chemin (/api) : il
est donc commun à TOUS les exemples. Une fois connecté ici, les exemples 19 et 20
le sont aussi.
=========================================================================
-->
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
interface Utilisateur {
login: string;
nom: string;
role: 'ADMIN' | 'DOCTOR' | 'USER';
}
interface Medecin {
id: number;
titre: string;
prenom: string;
nom: 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 20 utilisera vue-i18n)
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.',
};
const utilisateur = ref<Utilisateur | null>(null);
const medecins = ref<Medecin[]>([]);
const captcha = ref<{ id: string; svg: string } | null>(null);
const saisie = reactive({ login: 'admin', password: '', captcha: '' });
const erreur = ref<string | null>(null);
const pret = ref(false); // la vérification de départ est-elle terminée ?
async function chargerCaptcha(): Promise<void> {
captcha.value = await (await fetch('/api/captcha')).json();
saisie.captcha = '';
}
// 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
async function verifierSession(): Promise<void> {
const reponse = await fetch('/api/auth/moi');
if (reponse.ok) {
utilisateur.value = await reponse.json();
await chargerMedecins();
} else {
await chargerCaptcha();
}
pret.value = true;
}
// 2. la connexion
async function seConnecter(): Promise<void> {
erreur.value = null;
const reponse = await fetch('/api/auth/login', {
method: 'POST',
// le serveur refuse (415) un POST qui n'est pas en JSON
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: saisie.login,
password: saisie.password,
captchaId: captcha.value?.id,
captcha: saisie.captcha,
}),
});
if (reponse.ok) {
utilisateur.value = await reponse.json();
saisie.password = '';
await chargerMedecins();
return;
}
// une erreur : le corps JSON dit laquelle ; chaque image ne sert qu'une fois
const e = (await reponse.json()) as ErreurApi;
erreur.value = `${reponse.status} - ${MESSAGES[e.cle] ?? e.cle}`;
await chargerCaptcha();
}
// 3. une requête protégée
async function chargerMedecins(): Promise<void> {
const reponse = await fetch('/api/medecins');
medecins.value = reponse.ok ? await reponse.json() : [];
}
// 4. la déconnexion
async function seDeconnecter(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
utilisateur.value = null;
medecins.value = [];
await chargerCaptcha();
}
onMounted(() => {
verifierSession().catch(() => {
erreur.value = 'Le serveur JSON RdvMedecins est-il lancé (port 8080) ?';
pret.value = true;
});
});
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 18 - connexion au serveur</h1>
<p v-if="!pret" class="text-muted">Vérification de la session...</p>
<!-- connecté -->
<div v-else-if="utilisateur" class="card p-3">
<p>Connecté : <strong>{{ utilisateur.nom }}</strong> ({{ utilisateur.login }}),
rôle <span class="badge text-bg-primary">{{ utilisateur.role }}</span></p>
<h2 class="h6">GET /api/medecins</h2>
<ul>
<li v-for="m in medecins" :key="m.id">{{ 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>
<!-- pas connecté : le formulaire -->
<form v-else class="card p-3" @submit.prevent="seConnecter">
<div class="mb-2">
<label class="form-label" for="login">Login</label>
<input id="login" v-model="saisie.login" class="form-control" autocomplete="username">
</div>
<div class="mb-2">
<label class="form-label" for="password">Mot de passe</label>
<input id="password" v-model="saisie.password" type="password" class="form-control" autocomplete="current-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 v-if="captcha" v-html="captcha.svg"></div>
<a href="#" class="small" @click.prevent="chargerCaptcha">Autre image</a>
</div>
<input id="captcha" v-model="saisie.captcha" class="form-control" autocomplete="off">
</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>
<div v-if="erreur" class="alert alert-danger mt-3">{{ erreur }}</div>
</div>
</template>
|
- ligne 35 : la forme de toutes les erreurs de l'API ;
- ligne 42 : les quelques messages dont cet exemple a besoin, en français ; l'exemple 20 utilisera [vue-i18n] ;
- lignes 61-69 : au démarrage, [GET /api/auth/moi] : sommes-nous déjà connectés ? Après un rechargement de la page (F5), l'application [Vue.js] a tout oublié ; le serveur, lui, lit le cookie. 200 : l'utilisateur ; 401 : personne ;
- lignes 73-96 : la connexion :
- ligne 76 : [fetch(url, options)] : la méthode, les entêtes, le corps ;
- ligne 78 : le serveur refuse un POST qui n'est pas en JSON ;
- ligne 79 : le corps, converti en texte JSON ;
- ligne 87 : 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 93 : en cas d'échec, le corps dit pourquoi. On affiche le message et l'on demande une nouvelle image : chacune ne sert qu'une fois ;
- lignes 99-102 : 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 (par défaut, [fetch] envoie les cookies aux requêtes de même origine) ;
- lignes 105-110 : la déconnexion : le serveur efface le cookie. Le corps ['{}'] est là pour que la requête soit un POST JSON.
Le formulaire de connexion :

Avec un captcha mal recopié (le serveur est en mode test : n'importe quel captcha accepte la réponse [test]) :

Connecté :

Rechargez la page (F5) : vous restez connecté. Ouvrez ensuite l'exemple 19 : vous y êtes connecté aussi. Un cookie appartient à une machine (ici [localhost]) et à un chemin (ici [/api]), communs à tous les exemples.
8.3. Exemple 19 : une couche d'accès à l'API
L'exemple 18 répétait, à chaque appel de [fetch], les mêmes gestes : le préfixe [/api], l'entête [Content-Type], [JSON.stringify], le test de [reponse.ok], la lecture du corps d'erreur. On les regroupe dans une fonction unique, dans le dossier [commun] (partagé par les exemples 19 et 20) :
| // =========================================================================
// commun/api.ts - UNE fonction pour toutes les requêtes vers l'API
// -------------------------------------------------------------------------
// Dans l'exemple 18, chaque appel à fetch répétait les mêmes gestes :
// préfixe /api, entête Content-Type, JSON.stringify, test de reponse.ok,
// lecture du corps d'erreur... On les regroupe ici, une fois pour toutes :
//
// const medecins = await api.get<Medecin[]>('/medecins');
// await 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, 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).
// Utilisé par les exemples 19 et 20 ; l'étude de cas en reprend le principe.
// =========================================================================
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';
}
}
type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';
export async function requete<T>(methode: Methode, url: string, corps?: unknown): Promise<T> {
let reponse: Response;
try {
reponse = await fetch(`/api${url}`, {
method: methode,
// un corps -> du JSON (le serveur refuse un POST qui n'en est pas)
headers: corps !== undefined ? { 'Content-Type': 'application/json' } : {},
body: corps !== undefined ? JSON.stringify(corps) : undefined,
});
} catch {
// aucune réponse (réseau coupé...)
throw new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
}
// le corps est lu comme du texte, puis converti : une réponse vide, ou
// qui n'est pas du JSON (serveur JSON arrêté : c'est Vite qui répond),
// ne doit pas faire planter l'application
const texte = await reponse.text();
let donnees: unknown = null;
try {
donnees = texte ? JSON.parse(texte) : null;
} catch {
donnees = null;
}
if (!reponse.ok) {
const e = (donnees ?? {}) as { cle?: string; params?: Record<string, string>; champs?: Record<string, string> };
throw new ErreurApi(reponse.status, e.cle ?? 'ERRORS.SERVER_UNREACHABLE', e.params, e.champs);
}
return donnees as T;
}
// des raccourcis, plus lisibles à l'usage
export const api = {
get: <T>(url: string) => requete<T>('GET', url),
post: <T>(url: string, corps: unknown = {}) => requete<T>('POST', url, corps),
put: <T>(url: string, corps: unknown) => requete<T>('PUT', url, corps),
delete: <T>(url: string, corps?: unknown) => requete<T>('DELETE', url, corps),
};
|
- lignes 18-27 : 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 » ;
- ligne 32 : une fonction générique : [T] est le type du corps attendu en cas de succès. [requete<Personne[]>('GET', '/medecins')] renvoie une [Promise<Personne[]>] ;
- ligne 38 : un corps, et seulement dans ce cas, s'accompagne de l'entête JSON ;
- lignes 49-55 : le corps est lu comme du texte, puis converti. Une réponse vide, ou qui n'est pas du JSON (serveur arrêté : c'est [Vite] qui répond, avec du texte), ne doit pas faire planter l'application ;
- lignes 57-60 : un échec devient une [ErreurApi] : la promesse renvoyée par [requete] est rejetée. Le code appelant l'attrape par [try / catch] ;
- lignes 65-70 : des raccourcis, plus lisibles : [api.get<T>(url)], [api.post<T>(url, corps)]... [api.post] envoie par défaut le corps [{}].
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;
}
|
Un composant de connexion, réécrit avec [api.ts] et réduit à une barre :
| <!--
=========================================================================
commun/BarreConnexion.vue - connexion / déconnexion, en une barre
-------------------------------------------------------------------------
Le composant de l'exemple 18, réduit à une barre et réécrit avec
commun/api.ts. Il émet 'change' 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.
=========================================================================
-->
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { api, ErreurApi } from './api';
import type { Captcha, Utilisateur } from './types';
const emit = defineEmits<{ change: [utilisateur: Utilisateur | null] }>();
const utilisateur = ref<Utilisateur | null>(null);
const captcha = ref<Captcha | null>(null);
const saisie = reactive({ login: 'admin', password: '', captcha: '' });
const erreur = ref<string | null>(null);
async function nouveauCaptcha(): Promise<void> {
captcha.value = await api.get<Captcha>('/captcha');
saisie.captcha = '';
}
function changer(u: Utilisateur | null): void {
utilisateur.value = u;
emit('change', u);
}
onMounted(async () => {
try {
changer(await api.get<Utilisateur>('/auth/moi'));
} catch {
changer(null); // 401 : personne n'est connecté
await nouveauCaptcha().catch(() => (erreur.value = 'Le serveur JSON est-il lancé (port 8080) ?'));
}
});
async function seConnecter(): Promise<void> {
erreur.value = null;
try {
const u = await api.post<Utilisateur>('/auth/login', {
login: saisie.login,
password: saisie.password,
captchaId: captcha.value?.id,
captcha: saisie.captcha,
});
saisie.password = '';
changer(u);
} catch (e) {
erreur.value = e instanceof ErreurApi ? `${e.statusCode} ${e.cle}` : String(e);
await nouveauCaptcha();
}
}
async function seDeconnecter(): Promise<void> {
await api.post('/auth/logout');
changer(null);
await nouveauCaptcha();
}
</script>
<template>
<div class="alert alert-secondary d-flex flex-wrap align-items-center gap-2 py-2">
<template v-if="utilisateur">
<span>Connecté : <strong>{{ utilisateur.nom }}</strong> <span class="badge text-bg-primary">{{ utilisateur.role }}</span></span>
<button class="btn btn-sm btn-outline-secondary ms-auto" @click="seDeconnecter">Se déconnecter</button>
</template>
<form v-else class="d-flex flex-wrap align-items-center gap-2 w-100" @submit.prevent="seConnecter">
<input v-model="saisie.login" class="form-control form-control-sm w-auto" placeholder="login" autocomplete="username">
<input v-model="saisie.password" type="password" class="form-control form-control-sm w-auto" placeholder="mot de passe" autocomplete="current-password">
<div v-if="captcha" class="captcha" v-html="captcha.svg"></div>
<input v-model="saisie.captcha" class="form-control form-control-sm w-auto" placeholder="captcha" autocomplete="off" size="8">
<button class="btn btn-sm btn-primary">Se connecter</button>
<span v-if="erreur" class="text-danger small">{{ erreur }}</span>
</form>
</div>
</template>
<style scoped>
/* l'image du captcha, réduite pour tenir dans la barre */
.captcha :deep(svg) {
display: block;
height: 32px;
width: auto;
}
</style>
|
- ligne 16 : le composant émet [change] avec l'utilisateur connecté (ou [null]) chaque fois qu'il change, y compris au démarrage ;
- ligne 35 : avec [api.ts], une requête tient en une ligne ; en cas d'échec (401), on passe dans le [catch] ;
- ligne 54 : [instanceof] distingue une erreur de l'API d'une autre anomalie.
Au-dessus de [api.ts], une fonction par route de l'API :
| // =========================================================================
// 19-service-api/src/rdvmedecins.service.ts - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une fonction par route de l'API, typée. Les composants ne connaissent ni
// les URL, ni fetch, ni le format JSON : ils appellent des fonctions.
// 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 { api } from '../../commun/api';
import type { Agenda, Personne } from '../../commun/types';
export const rdvmedecins = {
getMedecins: () => api.get<Personne[]>('/medecins'),
getClients: () => api.get<Personne[]>('/clients'),
// URLSearchParams fabrique "?idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
getAgenda: (idMedecin: number, jour: string) =>
api.get<Agenda>(`/agenda?${new URLSearchParams({ idMedecin: String(idMedecin), jour })}`),
reserver: (jour: string, idCreneau: number, idClient?: number) =>
api.post<{ id: number }>('/rendez-vous', { jour, idCreneau, idClient }),
annuler: (idRv: number) => api.delete<Record<string, never>>(`/rendez-vous/${idRv}`),
};
|
C'est l'équivalent, côté client, de la couche DAO du serveur : les composants ne connaissent ni les URL, ni [fetch], ni le format JSON ; ils appellent des fonctions typées. Si l'API change, seul ce fichier change.
- ligne 18 : [URLSearchParams] fabrique la chaîne [idMedecin=1&jour=2030-01-15], en encodant les caractères qui doivent l'être.
La page :
| <!--
=========================================================================
19-service-api/src/App.vue - 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.
=========================================================================
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BarreConnexion from '../../commun/BarreConnexion.vue';
import { ErreurApi } from '../../commun/api';
import type { Agenda, CreneauAgenda, Personne, Utilisateur } from '../../commun/types';
import { rdvmedecins } from './rdvmedecins.service';
const utilisateur = ref<Utilisateur | null>(null);
const medecins = ref<Personne[]>([]);
const clients = ref<Personne[]>([]);
const idMedecin = ref<number | null>(null);
const jour = ref(demain());
const agenda = ref<Agenda | null>(null);
const idClient = ref<number | null>(null);
const message = ref<{ type: 'success' | 'danger'; texte: string } | null>(null);
const gestionnaire = computed(() => utilisateur.value !== null && utilisateur.value.role !== 'USER');
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')}`;
}
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)}`;
}
// une erreur de l'API devient un message (sa clé, faute de dictionnaire ici)
function signaler(e: unknown): void {
message.value = { 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
async function surConnexion(u: Utilisateur | null): Promise<void> {
utilisateur.value = u;
agenda.value = null;
medecins.value = [];
clients.value = [];
if (u === null) {
return;
}
try {
medecins.value = await rdvmedecins.getMedecins();
// la liste des patients : seulement pour ADMIN et DOCTOR (un USER recevrait 403)
clients.value = gestionnaire.value ? await rdvmedecins.getClients() : [];
} catch (e) {
signaler(e);
}
}
async function chargerAgenda(): Promise<void> {
if (idMedecin.value === null) {
return;
}
try {
agenda.value = await rdvmedecins.getAgenda(idMedecin.value, jour.value);
} catch (e) {
agenda.value = null;
signaler(e);
}
}
// l'agenda suit le choix du médecin et du jour
watch([idMedecin, jour], () => {
message.value = null;
chargerAgenda();
});
async function reserver(c: CreneauAgenda): Promise<void> {
try {
await rdvmedecins.reserver(jour.value, c.id, gestionnaire.value ? (idClient.value ?? undefined) : undefined);
message.value = { type: 'success', texte: `Créneau ${horaire(c)} réservé.` };
} catch (e) {
signaler(e);
}
await chargerAgenda();
}
async function annuler(c: CreneauAgenda): Promise<void> {
try {
await rdvmedecins.annuler(c.idRv as number);
message.value = { type: 'success', texte: `Rendez-vous de ${horaire(c)} annulé.` };
} catch (e) {
signaler(e);
}
await chargerAgenda();
}
</script>
<template>
<div class="container py-4">
<h1 class="h3 mb-3">Exemple 19 - une couche d'accès à l'API</h1>
<BarreConnexion @change="surConnexion" />
<template v-if="utilisateur">
<div class="row g-2 mb-3">
<div class="col-sm-5">
<select v-model="idMedecin" class="form-select">
<option :value="null" disabled>-- choisir un médecin --</option>
<option v-for="m in medecins" :key="m.id" :value="m.id">{{ m.titre }} {{ m.prenom }} {{ m.nom }}</option>
</select>
</div>
<div class="col-sm-3"><input v-model="jour" type="date" class="form-control"></div>
<div v-if="gestionnaire" class="col-sm-4">
<select v-model="idClient" class="form-select">
<option :value="null">-- patient à inscrire --</option>
<option v-for="c in clients" :key="c.id" :value="c.id">{{ c.titre }} {{ c.prenom }} {{ c.nom }}</option>
</select>
</div>
</div>
<div v-if="message" class="alert" :class="`alert-${message.type}`">{{ message.texte }}</div>
<!-- info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir -->
<div v-if="agenda?.info" class="alert alert-info">{{ agenda.info }}</div>
<table v-if="agenda" class="table table-sm align-middle">
<caption class="caption-top">Agenda du {{ agenda.jour }} - {{ agenda.medecin.titre }} {{ agenda.medecin.prenom }} {{ agenda.medecin.nom }}</caption>
<tbody>
<tr v-for="c in agenda.creneaux" :key="c.id">
<td>{{ horaire(c) }}</td>
<td>
<span v-if="c.libre" class="badge text-bg-success">Libre</span>
<span v-else-if="c.patient" class="badge" :class="c.moi ? 'text-bg-primary' : 'text-bg-secondary'">
{{ c.patient.titre }} {{ c.patient.prenom }} {{ c.patient.nom }}
</span>
<span v-else class="badge text-bg-secondary">Réservé</span>
</td>
<td class="text-end">
<!-- les boutons suivent les droits calculés par le serveur -->
<button v-if="c.reservable" class="btn btn-sm btn-outline-success" @click="reserver(c)">Réserver</button>
<button v-if="c.idRv !== null" class="btn btn-sm btn-outline-danger" @click="annuler(c)">Annuler</button>
</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
|
- ligne 27 : ADMIN ou DOCTOR ;
- lignes 45-60 : à 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 ;
- ligne 75 : [watch] peut surveiller plusieurs sources : l'agenda est rechargé quand le médecin OU le jour change ;
- lignes 80-87 : une réservation, puis, QUOI QU'IL ARRIVE, un rechargement de l'agenda. 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 141 et ligne 142 : les boutons suivent les droits calculés par le serveur ;
- ligne 137 : un créneau occupé dont le serveur n'a pas envoyé le patient (vu par un patient).
Connecté comme [admin], on choisit un médecin, un patient, et l'on réserve un créneau :

Réserver un second créneau pour le même patient : le serveur refuse. Faute de dictionnaire dans cet exemple, on affiche la clé brute :

8.4. Exemple 20 : les erreurs renvoyées par le serveur
Dernier exemple avant l'étude de cas : la gestion des clients, réservée à l'ADMIN. Ce formulaire ne fait aucune validation dans le navigateur : tout est envoyé au serveur, qui valide et répond, en cas de refus, par l'erreur de chaque champ. L'exemple 06 montrait l'autre démarche ; celle-ci a un avantage de taille : les règles n'existent qu'en UN endroit, le serveur, et le client ne peut pas les oublier. Son coût : un aller-retour avec le serveur avant d'afficher les erreurs.
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."
}
}
|
([locales/en.json] a les mêmes clés, en anglais.)
| import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App.vue';
import fr from './locales/fr.json';
import en from './locales/en.json';
// même configuration que l'exemple 16, en plus court
const i18n = createI18n({ legacy: false, locale: 'fr', fallbackLocale: 'fr', messages: { fr, en } });
createApp(App).use(i18n).mount('#app');
|
La page :
| <!--
=========================================================================
20-erreurs-serveur/src/App.vue - afficher les erreurs du SERVEUR
-------------------------------------------------------------------------
La gestion des clients (compte admin). Ce 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' } (verrou optimiste)
Le client traduit chaque clé (vue-i18n) et place chaque message sous son
champ, le message général en bas du formulaire. Essayez : un nom vide, un
titre modifié dans les outils de développement, un homonyme (Alice DURAND),
un login déjà pris (martin), la même fiche modifiée dans deux onglets...
=========================================================================
-->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import BarreConnexion from '../../commun/BarreConnexion.vue';
import { api, ErreurApi } from '../../commun/api';
import type { Personne, Utilisateur } from '../../commun/types';
// la fenêtre de confirmation de l'exemple 12, réutilisée telle quelle
import FenetreConfirmation from '../../12-confirmation/src/components/FenetreConfirmation.vue';
import { useConfirmation } from '../../12-confirmation/src/composables/useConfirmation';
const { t, locale } = useI18n();
const { confirmer } = useConfirmation();
const utilisateur = ref<Utilisateur | null>(null);
const clients = ref<Personne[]>([]);
const estAdmin = computed(() => utilisateur.value?.role === 'ADMIN');
// le formulaire : idEdite === null -> création, sinon modification
const idEdite = ref<number | null>(null);
const saisie = reactive({ titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: 0 });
// les erreurs renvoyées par le serveur
const erreurChamps = ref<Record<string, string>>({});
const erreurPage = ref<ErreurApi | null>(null);
const succes = ref<string | null>(null);
async function surConnexion(u: Utilisateur | null): Promise<void> {
utilisateur.value = u;
clients.value = [];
if (u?.role === 'ADMIN') {
await recharger();
}
}
async function recharger(): Promise<void> {
clients.value = await api.get<Personne[]>('/clients');
}
function nouveau(): void {
idEdite.value = null;
Object.assign(saisie, { titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: 0 });
effacerErreurs();
}
function editer(c: Personne): void {
idEdite.value = 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
Object.assign(saisie, { titre: c.titre, prenom: c.prenom, nom: c.nom, login: c.login ?? '', password: '', version: c.version });
effacerErreurs();
}
function effacerErreurs(): void {
erreurChamps.value = {};
erreurPage.value = null;
succes.value = null;
}
// le texte d'une erreur, dans la langue courante, avec ses paramètres
function texte(e: ErreurApi): string {
return t(e.cle, e.params);
}
async function enregistrer(): Promise<void> {
effacerErreurs();
const corps = { ...saisie };
try {
const client =
idEdite.value === null
? await api.post<Personne>('/clients', corps)
: await api.put<Personne>(`/clients/${idEdite.value}`, corps);
const message = t(idEdite.value === null ? 'FORM.AJOUTE' : 'FORM.MODIFIE', {
nom: `${client.prenom} ${client.nom}`,
login: client.login ?? '',
});
nouveau(); // formulaire vidé, messages effacés...
succes.value = message; // ... sauf celui-ci
await recharger();
} catch (e) {
if (!(e instanceof ErreurApi)) {
throw e;
}
// l'erreur de chaque champ, et le message général
erreurChamps.value = e.champs;
erreurPage.value = e;
}
}
async function supprimer(c: Personne): Promise<void> {
const nom = `${c.prenom} ${c.nom}`;
if (!(await confirmer(t('FORM.CONFIRMER_SUPPRESSION', { nom })))) {
return;
}
effacerErreurs();
try {
await api.delete(`/clients/${c.id}`);
succes.value = t('FORM.SUPPRIME', { nom });
} catch (e) {
erreurPage.value = e instanceof ErreurApi ? e : null;
}
await recharger();
}
</script>
<template>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Exemple 20 - les erreurs renvoyées par le serveur</h1>
<select v-model="locale" class="form-select form-select-sm w-auto">
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div>
<BarreConnexion @change="surConnexion" />
<p v-if="utilisateur && !estAdmin" class="alert alert-warning">{{ t('FORM.ADMIN_SEULEMENT') }}</p>
<div v-if="estAdmin" class="row g-4">
<div class="col-lg-6">
<table class="table table-sm align-middle">
<tbody>
<tr v-for="c in clients" :key="c.id">
<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)">{{ t('FORM.MODIFIER') }}</button>
<button class="btn btn-sm btn-outline-danger" @click="supprimer(c)">{{ t('FORM.SUPPRIMER') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-lg-6">
<form class="card p-3" novalidate @submit.prevent="enregistrer">
<h2 class="h5">{{ idEdite === null ? t('FORM.TITRE_AJOUT') : t('FORM.TITRE_MODIF') }}</h2>
<div class="mb-2">
<label class="form-label" for="titre">{{ t('FORM.TITRE') }}</label>
<!-- une option "Dr" que le serveur refusera : pour voir ERRORS.INVALID_TITLE -->
<select id="titre" v-model="saisie.titre" class="form-select" :class="{ 'is-invalid': erreurChamps.titre }">
<option>Mr</option><option>Mme</option><option>Melle</option><option>Dr</option>
</select>
<div class="invalid-feedback">{{ erreurChamps.titre && t(erreurChamps.titre) }}</div>
</div>
<div class="row g-2 mb-2">
<div class="col-sm-6">
<label class="form-label" for="prenom">{{ t('FORM.PRENOM') }}</label>
<input id="prenom" v-model="saisie.prenom" class="form-control" :class="{ 'is-invalid': erreurChamps.prenom }">
<div class="invalid-feedback">{{ erreurChamps.prenom && t(erreurChamps.prenom) }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="nom">{{ t('FORM.NOM') }}</label>
<input id="nom" v-model="saisie.nom" class="form-control" :class="{ 'is-invalid': erreurChamps.nom }">
<div class="invalid-feedback">{{ erreurChamps.nom && t(erreurChamps.nom) }}</div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<label class="form-label" for="login">{{ t('FORM.LOGIN') }}</label>
<input id="login" v-model="saisie.login" class="form-control" :class="{ 'is-invalid': erreurChamps.login }">
<div class="invalid-feedback">{{ erreurChamps.login && t(erreurChamps.login) }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="password">{{ t('FORM.PASSWORD') }}</label>
<input id="password" v-model="saisie.password" type="password" class="form-control" autocomplete="new-password"
:class="{ 'is-invalid': erreurChamps.password }">
<div class="invalid-feedback">{{ erreurChamps.password && t(erreurChamps.password) }}</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary">{{ t('FORM.ENREGISTRER') }}</button>
<button type="button" class="btn btn-secondary" @click="nouveau">{{ t('FORM.ANNULER') }}</button>
</div>
<div v-if="succes" class="alert alert-success mt-3 mb-0">{{ succes }}</div>
<!-- le message général, et le statut HTTP, pour comprendre -->
<div v-if="erreurPage" class="alert alert-danger mt-3 mb-0">
{{ texte(erreurPage) }} <span class="small text-muted">({{ erreurPage.statusCode }} {{ erreurPage.cle }})</span>
</div>
</form>
</div>
</div>
</div>
<FenetreConfirmation />
</template>
|
- ligne 26 : la fenêtre de confirmation de l'exemple 12, réutilisée telle quelle : un composant bien fait est réutilisable ;
- ligne 40 et ligne 41 : les erreurs renvoyées par le serveur : celle de chaque champ, et le message général ;
- lignes 62-68 : pour modifier une fiche, on recopie ses valeurs dans le formulaire, version comprise : elle repartira avec la modification (verrou optimiste) ;
- lignes 77-79 : le texte d'une erreur, dans la langue courante, avec ses paramètres ;
- lignes 81-102 : [POST] pour une création, [PUT] pour une modification. En cas de refus, on range les erreurs renvoyées ; le template fait le reste ;
- ligne 157 : un titre que le serveur refusera, pour voir [ERRORS.INVALID_TITLE] ;
- ligne 164 et ligne 165 : un champ refusé est encadré de rouge, et sa clé d'erreur traduite s'affiche dessous ;
- ligne 126 : changer de langue retraduit aussi les messages d'erreur déjà affichés : on n'a gardé que des clés.
Un formulaire mal rempli :

Un homonyme (Alice DURAND existe déjà) : le serveur répond 409 et désigne les champs prénom et nom :

La même erreur, en anglais :

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