Skip to content

7. Le serveur RdvMedecins, une boîte noire

Les exemples 22 à 25, puis l'étude de cas, dialoguent avec un serveur : le serveur JSON de l'application [RdvMedecins]. C'est le serveur [NestJS] du cours précédent, dont les contrôleurs ne fabriquent plus des pages HTML mais des réponses JSON. Ses couches métier et d'accès aux données sont inchangées.

Dans ce cours, ce serveur est une boîte noire : on n'a pas besoin de savoir comment il est écrit. Seule compte son API, c'est-à-dire la liste des requêtes HTTP qu'il accepte et des réponses qu'il renvoie. C'est la situation ordinaire d'un développeur de client : l'API est un contrat, et l'équipe du serveur peut changer tout ce qu'elle veut derrière ce contrat. Son code source est fourni (dossier [rdvmedecins-nestjs-json]) et abondamment commenté, pour le lecteur curieux.

Ce chapitre explique comment installer le serveur, décrit son API, et l'interroge avec [curl].

7.1. L'application RdvMedecins

L'application gère les rendez-vous d'un cabinet médical. Elle a trois rôles :

Rôle
Qui
Ce qu'il peut faire
[ADMIN]
l'administrateur ([admin])
prendre et annuler les rendez-vous de tous les patients ; gérer les médecins et les clients
[DOCTOR]
les médecins ([pelissier], [tatou], [dupont])
prendre et annuler les rendez-vous de tous les patients ; voir tous les noms dans les agendas
[USER]
les patients ([martin], [durand], [bistrou], [german])
réserver pour eux-mêmes ; annuler leur rendez-vous ; gérer leur compte. Dans un agenda, ils ne voient pas le nom des autres patients

Les comptes de démonstration ont pour mot de passe leur login. Les règles de gestion sont vérifiées par le serveur, quel que soit le client :

  • pas de rendez-vous dans le passé ;
  • un seul rendez-vous à venir par patient ;
  • un créneau n'est réservé qu'une fois par jour ;
  • deux médecins (ou deux clients) ne portent pas les mêmes prénom et nom ; un login n'appartient qu'à un compte ;
  • un médecin qui a des rendez-vous ne peut pas être supprimé ;
  • une fiche modifiée entre-temps par quelqu'un d'autre ne peut pas être écrasée (verrou optimiste) ;
  • un patient ne reçoit jamais le nom d'un autre patient.

7.2. Installation

Il faut [Node.js] et un serveur [MySQL] (ou [MariaDB]) : leur installation est décrite en annexe.

1. La base de données. Le script [database/dbrdvmedecins2.sql] crée la base [dbrdvmedecins2], ses tables et ses données de démonstration. Avec [Laragon], on l'exécute dans [HeidiSQL] (menu Fichier / Exécuter un fichier SQL) ; en ligne de commande :

mysql -u root -p < database/dbrdvmedecins2.sql

Le script repart toujours d'une base vide : on peut le relancer pour retrouver les données de départ.

2. Les bibliothèques. Dans une console ouverte sur le dossier [rdvmedecins-nestjs-json] :

npm install

3. La configuration. On copie le fichier [.env.example] en [.env] (sous [Windows] : [copy .env.example .env]), puis on l'adapte :

# -----------------------------------------------------------------------
# Fichier d'exemple de configuration.
# Copier ce fichier en [.env] puis adapter les valeurs à votre machine.
# Le fichier [.env] ne doit jamais être partagé ou commité (mots de passe).
# -----------------------------------------------------------------------

# port d'écoute du serveur JSON (le client Angular s'y adresse via le proxy de ng serve)
PORT=8080

# paramètres de connexion à la base de données MySQL [dbrdvmedecins2]
# (script : database/dbrdvmedecins2.sql)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
DB_DATABASE=dbrdvmedecins2
# true : affiche dans la console les requêtes SQL envoyées à MySQL
DB_LOGGING=false

# -----------------------------------------------------------------------
# authentification
# -----------------------------------------------------------------------
# clé secrète utilisée pour signer/vérifier les jetons JWT - à changer
# impérativement en production (une longue chaîne aléatoire, jamais commitée).
JWT_SECRET=change-moi-en-une-longue-chaine-aleatoire
# durée de validité d'un jeton JWT, en secondes (7200 s = 2 h). Le cookie
# qui transporte le jeton a la même durée de vie.
JWT_EXPIRES_IN_SECONDS=7200
# mettre à true en production (site servi en HTTPS) : le cookie
# d'authentification ne sera alors jamais envoyé sur une connexion non chiffrée.
COOKIE_SECURE=false

# -----------------------------------------------------------------------
# protection contre les robots
# -----------------------------------------------------------------------
# connexion : nombre d'échecs (mauvais captcha ou mot de passe) autorisés pour
# un même login depuis une même adresse IP, avant blocage de ce couple...
LOGIN_MAX_ECHECS=5
# ... pendant cette durée (une adresse IP est aussi bloquée si elle cumule
# 5 fois plus d'échecs, tous logins confondus)
LOGIN_BLOCAGE_MINUTES=15
# création de compte client : nombre maximal de comptes créés par heure
# depuis une même adresse IP. En salle de cours, tous les postes sortent
# souvent par la même adresse : augmenter si besoin.
INSCRIPTION_MAX_PAR_HEURE=10

# MODE TEST du captcha : si cette variable a une valeur, cette valeur est
# acceptée comme réponse à N'IMPORTE QUEL captcha. Pratique pour interroger
# l'API avec curl (on ne peut pas "lire" l'image dans une console).
# À laisser VIDE en production.
CAPTCHA_TEST=

# -----------------------------------------------------------------------
# production
# -----------------------------------------------------------------------
# dossier du client Angular compilé, servi par ce serveur (par défaut : client/)
# DOSSIER_CLIENT=client
# mettre à true si le serveur est derrière un proxy (nginx...)
TRUST_PROXY=false
  • ligne 8 : le serveur écoute sur le port 8080 ;
  • lignes 12-16 : la connexion à [MySQL]. Avec [Laragon], l'utilisateur [root] n'a pas de mot de passe : les valeurs par défaut conviennent ;
  • ligne 25 : la clé secrète qui signe les jetons d'authentification ;
  • lignes 38-45 : les protections contre les robots (blocage après plusieurs échecs de connexion, nombre de comptes créés par heure) ;
  • ligne 51 : le mode test du captcha. La page de connexion demande de recopier les caractères d'une image, ce qui est impossible depuis une console. Si cette variable a une valeur, cette valeur est acceptée comme réponse à n'importe quel captcha, comme les « clés de test » que [Google] fournit pour son [reCAPTCHA]. Pour les essais avec [curl] de ce chapitre, on écrit [CAPTCHA_TEST=test]. On la laisse VIDE en production ;
  • lignes 57-59 : deux réglages pour la production (cf. chapitre « Déploiement »).

4. Le lancement.

npm run start:dev

La console affiche (extrait) :

1
2
3
4
5
6
7
8
[Nest] LOG [NestFactory] Starting Nest application...
[Nest] LOG [RoutesResolver] AgendaController {/api/agenda}:
[Nest] LOG [RouterExplorer] Mapped {/api/agenda, GET} route
[Nest] LOG [RoutesResolver] RendezVousController {/api/rendez-vous}:
...
[Nest] WARN [RdvMedecins] MODE TEST du captcha actif (CAPTCHA_TEST dans .env) : à ne jamais utiliser en production
[Nest] LOG [NestApplication] Nest application successfully started
[Nest] LOG [RdvMedecins] serveur JSON démarré : http://localhost:8080/api

Si la base est inaccessible (serveur [MySQL] arrêté, mauvais mot de passe), le serveur s'arrête aussitôt avec un message d'erreur de connexion.

7.3. Le format des réponses

Toutes les routes commencent par [/api]. Une requête réussie reçoit un code 2xx et, dans son corps, les données demandées, en JSON. Une requête refusée reçoit un code 4xx ou 5xx et, TOUJOURS, un corps de la même forme :

1
2
3
4
5
6
{
  "statusCode": 400,
  "cle": "ERRORS.INVALID_FORM",
  "params": { "min": "6" },
  "champs": { "nom": "ERRORS.LASTNAME_REQUIRED", "password": "ERRORS.PASSWORD_TOO_SHORT" }
}
  • [statusCode] : le code HTTP, répété dans le corps ;
  • [cle] : la clé de traduction du message. Le serveur ne renvoie jamais de texte à afficher : il ne connaît plus la langue de l'utilisateur. C'est le client qui traduit la clé avec ses dictionnaires (cf. exemple 21) ;
  • [params] (facultatif) : les paramètres des messages ([Au moins {min} caractères], [Réessayez dans {minutes} minute(s)]) ;
  • [champs] (facultatif) : pour un formulaire refusé, l'erreur de chaque champ.

Les codes utilisés :

Code
Signification
Exemples de clés
400
requête invalide
[ERRORS.INVALID_FORM] (+ champs), [ERRORS.INVALID_DATE], [ERRORS.WRONG_CAPTCHA], [ERRORS.CHOOSE_PATIENT], [ERRORS.WRONG_CURRENT_PASSWORD], [ERRORS.BAD_REQUEST]...
401
pas authentifié
[ERRORS.AUTH_REQUIRED] (pas de jeton), [ERRORS.SESSION_EXPIRED] (jeton invalide ou expiré), [LOGIN.ERROR]
403
accès refusé
[ERRORS.FORBIDDEN] (rôle insuffisant), [ERRORS.CAPTCHA_BEFORE_ACCOUNT] (pas de laissez-passer)
404
introuvable
[ERRORS.NOT_FOUND], [ERRORS.DOCTOR_NOT_FOUND], [ERRORS.APPOINTMENT_NOT_FOUND]...
409
règle de gestion violée
[ERRORS.PAST_DATE], [ERRORS.SLOT_ALREADY_BOOKED], [ERRORS.LOGIN_TAKEN], [ERRORS.CONCURRENT_UPDATE]...
415
POST qui n'est pas en JSON
[ERRORS.JSON_REQUIRED]
429
trop de tentatives
[ERRORS.TOO_MANY_ATTEMPTS], [ERRORS.TOO_MANY_ACCOUNTS] (+ params.minutes)
500
anomalie du serveur
[ERRORS.SERVER_ERROR]

7.4. L'authentification

Le client s'authentifie par [POST /api/auth/login], avec login, mot de passe et réponse au captcha. En retour, le serveur dépose dans le navigateur un cookie qui contient un jeton JWT signé :

Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; HttpOnly; SameSite=Strict

Ses attributs comptent pour la sécurité :

  • [HttpOnly] : le code [JavaScript] de la page ne peut PAS lire ce cookie. Un script malveillant qui aurait été injecté dans la page ne pourrait donc pas voler le jeton. Conséquence pour le client : il ne sait pas, par lui-même, s'il est connecté. Il le demande au serveur ([GET /api/auth/moi]) ;
  • [Path=/api] : le navigateur ne le joint qu'aux requêtes vers l'API ;
  • [SameSite=Strict] : le navigateur ne le joint qu'aux requêtes émises DEPUIS une page de ce site. Une page piégée sur un autre site ne peut pas faire agir le navigateur au nom de l'utilisateur (attaque dite CSRF) ;
  • [Max-Age=7200] : il disparaît au bout de deux heures, en même temps que le jeton expire.

Le navigateur présente ensuite ce cookie tout seul, à chaque requête vers [/api]. Le client [Angular] n'a donc RIEN à faire pour s'authentifier, à une condition : ses pages et l'API doivent avoir la même origine (même protocole, même machine, même port). C'est le rôle du proxy du serveur de développement d'[Angular] (cf. exemple 22), et du serveur lui-même en production (cf. chapitre « Déploiement »).

Une seconde protection contre les attaques CSRF : le serveur refuse (415) toute requête POST dont le corps n'est pas en JSON. Un formulaire HTML piégé, sur un autre site, ne sait pas envoyer de JSON.

7.5. L'API

Méthode
Adresse
Rôles
Corps envoyé / réponse
GET
[/api/captcha]
public
réponse : [{ id, svg }]
POST
[/api/auth/login]
public
[{ login, password, captchaId, captcha }] ; réponse : [{ login, nom, role }] + cookie
POST
[/api/auth/logout]
public
[{}] ; efface le cookie
GET
[/api/auth/moi]
connecté
réponse : [{ login, nom, role }]
GET
[/api/agenda?idMedecin=&jour=]
tous
réponse : l'agenda (sans idMedecin : pour un DOCTOR, le sien)
POST
[/api/rendez-vous]
tous
[{ jour, idCreneau, idClient }] (idClient ignoré pour un USER) ; 201
DELETE
[/api/rendez-vous/:id]
tous
un USER n'annule que le sien
GET
[/api/medecins]
tous
la liste (avec login pour l'ADMIN)
GET
[/api/clients]
ADMIN, DOCTOR
la liste (avec login pour l'ADMIN)
GET
[/api/medecins/:id], [/api/clients/:id]
ADMIN
une fiche
POST
[/api/medecins], [/api/clients]
ADMIN
[{ titre, prenom, nom, login?, password? }] ; 201
PUT
[/api/medecins/:id], [/api/clients/:id]
ADMIN
[{ titre, prenom, nom, login?, password?, version }]
DELETE
[/api/medecins/:id], [/api/clients/:id]
ADMIN
réponse : [{}]
POST
[/api/inscription/acces]
public
[{ captchaId, captcha }] ; délivre un laissez-passer (cookie)
GET
[/api/inscription/acces]
public
réponse : [{ valide }]
POST
[/api/inscription]
public + laissez-passer
[{ titre, prenom, nom, login, password, confirmation }] ; 201
GET
[/api/mon-espace]
USER
réponse : [{ patient, rendezVous }]
GET
[/api/mon-compte]
USER
réponse : [{ titre, prenom, nom, login, version }]
PUT
[/api/mon-compte]
USER
[{ titre, prenom, nom, login, password?, confirmation?, motDePasseActuel, version }]
DELETE
[/api/mon-compte]
USER
[{ motDePasseActuel }] ; supprime le compte et ses rendez-vous

Une ligne de l'agenda ([GET /api/agenda]) décrit un créneau ET ce que l'utilisateur connecté a le droit d'en faire :

1
2
3
4
{ "id": 3, "hdebut": 8, "mdebut": 40, "hfin": 9, "mfin": 0,
  "libre": false,
  "patient": { "titre": "Mme", "prenom": "Alice", "nom": "DURAND" },
  "moi": false, "reservable": false, "idRv": 2 }
  • [libre] : le créneau est-il libre ce jour-là ?
  • [patient] : le patient du rendez-vous. Pour un patient connecté, il vaut [null] sauf pour SON rendez-vous : le nom des autres patients n'est jamais envoyé ;
  • [moi] : c'est le rendez-vous du patient connecté ;
  • [reservable] : l'utilisateur peut-il réserver ce créneau (libre, pas dans le passé, et, pour un patient, pas de rendez-vous déjà à venir) ?
  • [idRv] : renseigné si l'utilisateur peut annuler ce rendez-vous.

C'est le serveur qui DÉCIDE ; le client affiche. Et même si un client trichait (en affichant un bouton « Réserver » qu'il ne devrait pas), le serveur revérifierait tout au moment de la réservation.

7.6. Interroger l'API avec curl

Le dossier [curl] du serveur contient les corps JSON des requêtes de ce chapitre, un fichier par requête. Par exemple, [curl/login-admin.json] :

{ "login": "admin", "password": "admin", "captchaId": "-", "captcha": "test" }

Les commandes se tapent dans une console ouverte sur le dossier [rdvmedecins-nestjs-json]. Quelques options de [curl] :

  • [-i] : afficher aussi les entêtes de la réponse ; [-s] : sans barre de progression ;
  • [--json @fichier] : envoyer le contenu du fichier en POST, avec l'entête [Content-Type: application/json] (curl 7.82 et plus ; avec une version plus ancienne, on écrit [-H "Content-Type: application/json" -d @fichier]) ;
  • [-X PUT], [-X DELETE] : la méthode HTTP ;
  • [-c cookies.txt] : enregistrer les cookies reçus dans ce fichier ; [-b cookies.txt] : les renvoyer. C'est ainsi que [curl] « reste connecté » d'une commande à l'autre, comme le ferait un navigateur.

Sous [PowerShell], il faut taper [curl.exe] et non [curl] (qui y désigne une autre commande). Les réponses JSON un peu longues ont été mises en forme pour ce document, et les entêtes [Date], [ETag], [Connection] et [Keep-Alive] retirés.

7.6.1. Connexion

Le captcha d'abord (l'image est tronquée) :

1
2
3
4
5
6
7
$ curl -i http://localhost:8080/api/captcha
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json; charset=utf-8
Content-Length: 11397

{"id": "9931064f-dcc3-45f7-a35c-3057d208a1d4", "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"150..."}

Sans jeton, une route protégée répond 401 :

1
2
3
4
5
6
$ curl -i http://localhost:8080/api/medecins
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 47

{"statusCode": 401, "cle": "ERRORS.AUTH_REQUIRED"}

Un mauvais captcha, puis un mauvais mot de passe :

1
2
3
4
5
6
$ curl -i --json @curl/login-mauvais-captcha.json http://localhost:8080/api/auth/login
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 91

{"statusCode": 400, "cle": "ERRORS.WRONG_CAPTCHA", "champs": {"captcha": "ERRORS.WRONG_CAPTCHA"}}
1
2
3
4
5
6
$ curl -i --json @curl/login-mauvais-mdp.json http://localhost:8080/api/auth/login
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 38

{"statusCode": 401, "cle": "LOGIN.ERROR"}

La connexion réussie dépose le cookie, que [curl] range dans [cookies.txt] :

1
2
3
4
5
6
7
$ curl -i -c cookies.txt --json @curl/login-admin.json http://localhost:8080/api/auth/login
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; Expires=Mon, 28 Sep 2026 12:49:12 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"login": "admin", "nom": "Administrateur", "role": "ADMIN"}

Avec ce cookie, le serveur sait qui nous sommes :

1
2
3
4
5
6
$ curl -i -b cookies.txt http://localhost:8080/api/auth/moi
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"login": "admin", "nom": "Administrateur", "role": "ADMIN"}

Un jeton falsifié (ou expiré) donne une session expirée, et le serveur demande au navigateur d'effacer le cookie :

1
2
3
4
5
6
7
$ curl -i -b "rdvmedecins_jwt=abc.def.ghi" http://localhost:8080/api/auth/moi
HTTP/1.1 401 Unauthorized
Set-Cookie: rdvmedecins_jwt=; Path=/api; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 49

{"statusCode": 401, "cle": "ERRORS.SESSION_EXPIRED"}

7.6.2. Médecins, clients, agenda

La liste des médecins, vue par l'ADMIN (avec les logins) :

$ curl -i -b cookies.txt http://localhost:8080/api/medecins
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 258

[
  {"id": 3, "version": 0, "titre": "Melle", "prenom": "Sophie", "nom": "DUPONT", "login": "dupont"},
  {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER", "login": "pelissier"},
  {"id": 2, "version": 0, "titre": "Mr", "prenom": "Paul", "nom": "TATOU", "login": "tatou"}
]

L'agenda du docteur PELISSIER le 15 janvier 2030 :

$ curl -s -b cookies.txt "http://localhost:8080/api/agenda?idMedecin=1&jour=2030-01-15"
{
  "medecin": {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER"},
  "jour": "2030-01-15",
  "creneaux": [
    {"id": 1, "hdebut": 8, "mdebut": 0, "hfin": 8, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 2, "hdebut": 8, "mdebut": 20, "hfin": 8, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 3, "hdebut": 8, "mdebut": 40, "hfin": 9, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 4, "hdebut": 9, "mdebut": 0, "hfin": 9, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 5, "hdebut": 9, "mdebut": 20, "hfin": 9, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 6, "hdebut": 9, "mdebut": 40, "hfin": 10, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 7, "hdebut": 10, "mdebut": 0, "hfin": 10, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 8, "hdebut": 10, "mdebut": 20, "hfin": 10, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 9, "hdebut": 10, "mdebut": 40, "hfin": 11, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 10, "hdebut": 11, "mdebut": 0, "hfin": 11, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 11, "hdebut": 11, "mdebut": 20, "hfin": 11, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 12, "hdebut": 11, "mdebut": 40, "hfin": 12, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null}
  ],
  "info": null
}

Une date qui n'existe pas :

1
2
3
4
5
6
$ curl -i -b cookies.txt "http://localhost:8080/api/agenda?idMedecin=1&jour=2030-02-30"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 46

{"statusCode": 400, "cle": "ERRORS.INVALID_DATE"}

7.6.3. Rendez-vous

L'ADMIN réserve le créneau 3 pour la patiente n° 2 (Alice DURAND). Le corps de la requête :

{ "jour": "2030-01-15", "idCreneau": 3, "idClient": 2 }
1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/rv-admin.json http://localhost:8080/api/rendez-vous
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"id": 2, "jour": "2030-01-15", "idCreneau": 3, "idClient": 2}

L'agenda montre maintenant ce rendez-vous, avec le nom de la patiente et le moyen de l'annuler ([idRv]) :

$ curl -s -b cookies.txt "http://localhost:8080/api/agenda?idMedecin=1&jour=2030-01-15"
{
  "medecin": {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER"},
  "jour": "2030-01-15",
  "creneaux": [
    {"id": 1, "hdebut": 8, "mdebut": 0, "hfin": 8, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 2, "hdebut": 8, "mdebut": 20, "hfin": 8, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 3, "hdebut": 8, "mdebut": 40, "hfin": 9, "mfin": 0, "libre": false, "patient": {"titre": "Mme", "prenom": "Alice", "nom": "DURAND"}, "moi": false, "reservable": false, "idRv": 2},
    {"id": 4, "hdebut": 9, "mdebut": 0, "hfin": 9, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 5, "hdebut": 9, "mdebut": 20, "hfin": 9, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 6, "hdebut": 9, "mdebut": 40, "hfin": 10, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 7, "hdebut": 10, "mdebut": 0, "hfin": 10, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 8, "hdebut": 10, "mdebut": 20, "hfin": 10, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 9, "hdebut": 10, "mdebut": 40, "hfin": 11, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 10, "hdebut": 11, "mdebut": 0, "hfin": 11, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 11, "hdebut": 11, "mdebut": 20, "hfin": 11, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 12, "hdebut": 11, "mdebut": 40, "hfin": 12, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null}
  ],
  "info": null
}

Recommencer : la patiente a déjà un rendez-vous à venir. Un rendez-vous dans le passé est refusé lui aussi :

1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/rv-admin.json http://localhost:8080/api/rendez-vous
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 66

{"statusCode": 409, "cle": "ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT"}
1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/rv-passe.json http://localhost:8080/api/rendez-vous
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 43

{"statusCode": 409, "cle": "ERRORS.PAST_DATE"}

Un formulaire HTML ordinaire (ici simulé par [-d], qui envoie [application/x-www-form-urlencoded]) est refusé :

1
2
3
4
5
6
$ curl -i -b cookies.txt -d "jour=2030-01-15&idCreneau=4&idClient=2" http://localhost:8080/api/rendez-vous
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json; charset=utf-8
Content-Length: 47

{"statusCode": 415, "cle": "ERRORS.JSON_REQUIRED"}

7.6.4. Le point de vue d'un patient

On se déconnecte, puis l'on se connecte comme [martin] :

1
2
3
4
5
6
7
$ curl -i -b cookies.txt -c cookies.txt --json @curl/vide.json http://localhost:8080/api/auth/logout
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_jwt=; Path=/api; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}
$ curl -s -c cookies-martin.txt --json @curl/login-martin.json http://localhost:8080/api/auth/login
{"login": "martin", "nom": "Jean MARTIN", "role": "USER"}

Le même agenda, vu par un patient : le créneau 3 est occupé (["libre": false]), mais ["patient"] vaut [null] : le nom d'Alice DURAND n'a pas été envoyé :

$ curl -s -b cookies-martin.txt "http://localhost:8080/api/agenda?idMedecin=1&jour=2030-01-15"
{
  "medecin": {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER"},
  "jour": "2030-01-15",
  "creneaux": [
    {"id": 1, "hdebut": 8, "mdebut": 0, "hfin": 8, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 2, "hdebut": 8, "mdebut": 20, "hfin": 8, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 3, "hdebut": 8, "mdebut": 40, "hfin": 9, "mfin": 0, "libre": false, "patient": null, "moi": false, "reservable": false, "idRv": null},
    {"id": 4, "hdebut": 9, "mdebut": 0, "hfin": 9, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 5, "hdebut": 9, "mdebut": 20, "hfin": 9, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 6, "hdebut": 9, "mdebut": 40, "hfin": 10, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 7, "hdebut": 10, "mdebut": 0, "hfin": 10, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 8, "hdebut": 10, "mdebut": 20, "hfin": 10, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 9, "hdebut": 10, "mdebut": 40, "hfin": 11, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 10, "hdebut": 11, "mdebut": 0, "hfin": 11, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 11, "hdebut": 11, "mdebut": 20, "hfin": 11, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 12, "hdebut": 11, "mdebut": 40, "hfin": 12, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null}
  ],
  "info": null
}

Un patient n'a pas accès à la liste des clients :

1
2
3
4
5
6
$ curl -i -b cookies-martin.txt http://localhost:8080/api/clients
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
Content-Length: 43

{"statusCode": 403, "cle": "ERRORS.FORBIDDEN"}

Il réserve pour lui-même (le corps ne contient pas d'[idClient]), une fois, pas deux :

{ "jour": "2030-01-15", "idCreneau": 5 }
1
2
3
4
5
6
$ curl -i -b cookies-martin.txt --json @curl/rv-martin.json http://localhost:8080/api/rendez-vous
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"id": 3, "jour": "2030-01-15", "idCreneau": 5, "idClient": 1}
1
2
3
4
5
6
$ curl -i -b cookies-martin.txt --json @curl/rv-martin-2.json http://localhost:8080/api/rendez-vous
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 53

{"statusCode": 409, "cle": "AGENDA.ALREADY_BOOKED_INFO"}

Son espace :

$ curl -s -b cookies-martin.txt http://localhost:8080/api/mon-espace
{
  "patient": {"titre": "Mr", "prenom": "Jean", "nom": "MARTIN"},
  "rendezVous": [
    {
      "id": 3,
      "jour": "2030-01-15",
      "creneau": {"id": 5, "hdebut": 9, "mdebut": 20, "hfin": 9, "mfin": 40},
      "medecin": {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER"}
    }
  ]
}

Il ne peut pas annuler le rendez-vous d'un autre patient. Le serveur répond « introuvable », exactement comme pour un rendez-vous qui n'existe pas : il ne révèle rien des rendez-vous des autres :

1
2
3
4
5
6
$ curl -i -b cookies-martin.txt -X DELETE http://localhost:8080/api/rendez-vous/2
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"statusCode": 404, "cle": "ERRORS.APPOINTMENT_NOT_FOUND"}

Le sien, oui :

1
2
3
4
5
6
$ curl -i -b cookies-martin.txt -X DELETE http://localhost:8080/api/rendez-vous/3
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}

7.6.5. Le point de vue d'un médecin

Sans [idMedecin], un médecin connecté reçoit SON agenda, avec tous les noms :

$ curl -s -b cookies-pelissier.txt "http://localhost:8080/api/agenda?jour=2030-01-15"
{
  "medecin": {"id": 1, "version": 0, "titre": "Mme", "prenom": "Marie", "nom": "PELISSIER"},
  "jour": "2030-01-15",
  "creneaux": [
    {"id": 1, "hdebut": 8, "mdebut": 0, "hfin": 8, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 2, "hdebut": 8, "mdebut": 20, "hfin": 8, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 3, "hdebut": 8, "mdebut": 40, "hfin": 9, "mfin": 0, "libre": false, "patient": {"titre": "Mme", "prenom": "Alice", "nom": "DURAND"}, "moi": false, "reservable": false, "idRv": 2},
    {"id": 4, "hdebut": 9, "mdebut": 0, "hfin": 9, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 5, "hdebut": 9, "mdebut": 20, "hfin": 9, "mfin": 40, "libre": false, "patient": {"titre": "Mr", "prenom": "Jean", "nom": "MARTIN"}, "moi": false, "reservable": false, "idRv": 3},
    {"id": 6, "hdebut": 9, "mdebut": 40, "hfin": 10, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 7, "hdebut": 10, "mdebut": 0, "hfin": 10, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 8, "hdebut": 10, "mdebut": 20, "hfin": 10, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 9, "hdebut": 10, "mdebut": 40, "hfin": 11, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 10, "hdebut": 11, "mdebut": 0, "hfin": 11, "mfin": 20, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 11, "hdebut": 11, "mdebut": 20, "hfin": 11, "mfin": 40, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null},
    {"id": 12, "hdebut": 11, "mdebut": 40, "hfin": 12, "mfin": 0, "libre": true, "patient": null, "moi": false, "reservable": true, "idRv": null}
  ],
  "info": null
}

Il ne gère pas les médecins :

1
2
3
4
5
6
$ curl -i -b cookies-pelissier.txt --json @curl/medecin-ajout.json http://localhost:8080/api/medecins
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
Content-Length: 43

{"statusCode": 403, "cle": "ERRORS.FORBIDDEN"}

7.6.6. La gestion des médecins et des clients (ADMIN)

Un ajout. Le login, absent, vaut par défaut le nom en minuscules ; le mot de passe, le login :

{ "titre": "Mme", "prenom": "Claire", "nom": "LEROY" }
1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/medecin-ajout.json http://localhost:8080/api/medecins
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 82

{"id": 4, "version": 1, "titre": "Mme", "prenom": "Claire", "nom": "LEROY", "login": "leroy"}

Un corps invalide : la réponse donne l'erreur de chaque champ :

{ "titre": "Dr", "prenom": "", "nom": "UN-NOM-BIEN-TROP-LONG-POUR-LA-BASE", "login": "Claire L" }
$ curl -i -b cookies.txt --json @curl/medecin-invalide.json http://localhost:8080/api/medecins
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 210

{
  "statusCode": 400,
  "cle": "ERRORS.INVALID_FORM",
  "params": {"min": "6"},
  "champs": {"login": "ERRORS.INVALID_LOGIN", "titre": "ERRORS.INVALID_TITLE", "prenom": "ERRORS.FIRSTNAME_REQUIRED", "nom": "ERRORS.LASTNAME_TOO_LONG"}
}

Un homonyme : les deux champs concernés sont signalés :

1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/medecin-doublon.json http://localhost:8080/api/medecins
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 143

{"statusCode": 409, "cle": "ERRORS.DOCTOR_ALREADY_EXISTS", "champs": {"prenom": "ERRORS.DOCTOR_ALREADY_EXISTS", "nom": "ERRORS.DOCTOR_ALREADY_EXISTS"}}

Une modification. La [version] est celle lue avec la fiche (ici 1) :

{ "titre": "Mme", "prenom": "Claire", "nom": "LEROY-MARTIN", "version": 1 }
1
2
3
4
5
6
$ curl -i -b cookies.txt -X PUT --json @curl/medecin-modif.json http://localhost:8080/api/medecins/4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 89

{"id": 4, "version": 2, "titre": "Mme", "prenom": "Claire", "nom": "LEROY-MARTIN", "login": "leroy"}

La fiche est maintenant en version 2. Renvoyer la même modification (avec la version 1) revient à écraser une fiche modifiée entre-temps par quelqu'un d'autre : c'est le verrou optimiste qui refuse :

1
2
3
4
5
6
$ curl -i -b cookies.txt -X PUT --json @curl/medecin-modif.json http://localhost:8080/api/medecins/4
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 51

{"statusCode": 409, "cle": "ERRORS.CONCURRENT_UPDATE"}

Un médecin qui a des rendez-vous ne peut pas être supprimé ; les autres, si :

1
2
3
4
5
6
$ curl -i -b cookies.txt -X DELETE http://localhost:8080/api/medecins/1
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 57

{"statusCode": 409, "cle": "ERRORS.DOCTOR_HAS_APPOINTMENTS"}
1
2
3
4
5
6
$ curl -i -b cookies.txt -X DELETE http://localhost:8080/api/medecins/4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}

7.6.7. La création d'un compte

Sans laissez-passer, impossible :

1
2
3
4
5
6
$ curl -i --json @curl/inscription.json http://localhost:8080/api/inscription
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
Content-Length: 56

{"statusCode": 403, "cle": "ERRORS.CAPTCHA_BEFORE_ACCOUNT"}

On résout d'abord un captcha ; le serveur délivre un laissez-passer, valable 15 minutes, dans un cookie :

1
2
3
4
5
6
7
$ curl -i -c cookies-inscription.txt --json @curl/captcha-test.json http://localhost:8080/api/inscription/acces
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_inscription=1c3352aa-26ba-4f85-9f61-ed2c6a731d84; Max-Age=900; Path=/api/inscription; Expires=Mon, 28 Sep 2026 11:04:12 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}
$ curl -s -b cookies-inscription.txt http://localhost:8080/api/inscription/acces
{"valide": true}

La confirmation du mot de passe doit être identique :

1
2
3
4
5
6
$ curl -i -b cookies-inscription.txt --json @curl/inscription-confirmation.json http://localhost:8080/api/inscription
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 98

{"statusCode": 400, "cle": "ERRORS.INVALID_FORM", "champs": {"confirmation": "ERRORS.PASSWORDS_DIFFER"}}
{ "titre": "Mme", "prenom": "Julie", "nom": "BERNARD", "login": "jbernard", "password": "secret1", "confirmation": "secret1" }
1
2
3
4
5
6
7
$ curl -i -b cookies-inscription.txt -c cookies-inscription.txt --json @curl/inscription.json http://localhost:8080/api/inscription
HTTP/1.1 201 Created
Set-Cookie: rdvmedecins_inscription=; Path=/api/inscription; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 20

{"login": "jbernard"}

Le laissez-passer a servi : il ne permet pas de créer un second compte :

1
2
3
4
5
6
$ curl -i -b cookies-inscription.txt --json @curl/inscription.json http://localhost:8080/api/inscription
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
Content-Length: 56

{"statusCode": 403, "cle": "ERRORS.CAPTCHA_BEFORE_ACCOUNT"}

7.6.8. Gérer son compte

La nouvelle patiente se connecte et lit sa fiche :

$ curl -s -b cookies-jbernard.txt http://localhost:8080/api/mon-compte
{"titre": "Mme", "prenom": "Julie", "nom": "BERNARD", "login": "jbernard", "version": 1}

Une modification exige le mot de passe actuel. Le serveur délivre un nouveau jeton, qui contient le nouveau nom :

{ "titre": "Mme", "prenom": "Julie", "nom": "BERNARD-DUVAL", "login": "jbernard", "motDePasseActuel": "secret1", "version": 1 }
1
2
3
4
5
6
7
$ curl -i -b cookies-jbernard.txt -c cookies-jbernard.txt -X PUT --json @curl/mon-compte.json http://localhost:8080/api/mon-compte
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; Expires=Mon, 28 Sep 2026 12:49:13 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 62

{"login": "jbernard", "nom": "Julie BERNARD-DUVAL", "role": "USER"}

La suppression du compte efface aussi le cookie :

1
2
3
4
5
6
7
$ curl -i -b cookies-jbernard.txt -c cookies-jbernard.txt -X DELETE --json @curl/mon-compte-suppression.json http://localhost:8080/api/mon-compte
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_jwt=; Path=/api; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}
1
2
3
4
5
6
$ curl -i -b cookies-jbernard.txt http://localhost:8080/api/auth/moi
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 47

{"statusCode": 401, "cle": "ERRORS.AUTH_REQUIRED"}

7.6.9. Quelques erreurs

Une route inconnue, un corps qui n'est pas du JSON valide :

1
2
3
4
5
6
$ curl -i -b cookies-martin.txt http://localhost:8080/api/n-existe-pas
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 43

{"statusCode": 404, "cle": "ERRORS.NOT_FOUND"}
1
2
3
4
5
6
$ curl -i -b cookies.txt -H "Content-Type: application/json" -d "{pas du json" http://localhost:8080/api/rendez-vous
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 45

{"statusCode": 400, "cle": "ERRORS.BAD_REQUEST"}

Nous connaissons maintenant tout ce qu'il faut savoir du serveur pour écrire son client.