Skip to content

8. The RdvMedecins server, a black box

Examples 22 through 25, followed by the case study, interact with a server: the JSON server of the [RdvMedecins] application. This is the [NestJS] server for courses [React], [Vue.js], [Angular], and [Flutter]: the server for the [NestJS] course, whose controllers no longer generate HTML pages but instead produce JSON responses. Readers of these lectures are already familiar with this chapter, which is reproduced here almost unchanged; they need only consult the section on authentication, which explains what changes for a [Ionic] application installed on a phone.

In this course, this server is a black box: you don’t need to know how it’s written. All that matters is its API—that is, the list of HTTP requests it accepts and the responses it returns. This is the typical situation for a client developer: the API is a contract, and the server team can change whatever they want behind that contract. Its source code is provided ([rdvmedecins-nestjs-json] folder) and extensively commented, for the curious reader.

This chapter explains how to install the server, describes its API, and queries it using [curl].

8.1. The RdvMedecins Application

The application manages appointments for a medical practice. It has three roles:

Role
Who
What they can do
[ADMIN]
The administrator ([admin])
Schedule and cancel appointments for all patients; manage doctors and clients
[DOCTOR]
Doctors ([pelissier], [tatou], [dupont])
Schedule and cancel appointments for all patients; view all names in the calendars
[USER]
Patients ([martin], [durand], [bistrou], [german])
book appointments for themselves; cancel their appointments; manage their accounts. In a calendar, they cannot see the names of other patients

Demo accounts use their login as the password. The management rules are verified by the server, regardless of the client:

  • no appointments in the past;
  • only one upcoming appointment per patient;
  • a time slot can only be booked once per day;
  • two doctors (or two clients) cannot have the same first and last names; a login belongs to only one account;
  • a doctor with scheduled appointments cannot be deleted;
  • A record that has been modified in the meantime by someone else cannot be overwritten (optimistic lock);
  • A patient is never assigned the name of another patient.

8.2. Installation

You need [Node.js] (already installed for [Angular]; see Chapter 2) and a [MySQL] (or [MariaDB]) server: their installation is described in the appendix.

1. The database. The [database/dbrdvmedecins2.sql] script creates the [dbrdvmedecins2] database, its tables, and its sample data. Using [Laragon], run it from within [HeidiSQL] (File menu / Run a SQL file); at the command line:

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

The script always starts with an empty database: you can rerun it to restore the initial data.

2. Libraries. In a console window open to the [rdvmedecins-nestjs-json] folder:

npm install

3. Configuration. Copy the file [.env.example] to [.env] (under [Windows]: [copy .env.example .env]), then modify it:

# -----------------------------------------------------------------------
# Sample configuration file.
# Copy this file to [.env] then adapt the values to your machine.
# The [.env] file must never be shared or committed (passwords).
# -----------------------------------------------------------------------

# listening port of the JSON server
PORT=8080

# connection parameters for the MySQL database [dbrdvmedecins2]
# (script: database/dbrdvmedecins2.sql)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
DB_DATABASE=dbrdvmedecins2
# true: displays in the console the SQL queries sent to MySQL
DB_LOGGING=false

# -----------------------------------------------------------------------
# authentication
# -----------------------------------------------------------------------
# secret key used to sign/verify JWT tokens - MUST be changed
# in production (a long random string, never committed).
JWT_SECRET=change-me-to-a-long-random-string
# validity of a JWT token, in seconds (7200 s = 2 h). The cookie
# that carries the token has the same lifetime.
JWT_EXPIRES_IN_SECONDS=7200
# set to true in production (site served over HTTPS): the authentication
# cookie will then never be sent over an unencrypted connection.
COOKIE_SECURE=false

# -----------------------------------------------------------------------
# protection against bots
# -----------------------------------------------------------------------
# login: number of failures (wrong captcha or password) allowed for
# the same login from the same IP address, before this pair is blocked...
LOGIN_MAX_FAILURES=5
# ... for this duration (an IP address is also blocked if it accumulates
# 5 times more failures, all logins combined)
LOGIN_BLOCK_MINUTES=15
# client account creation: maximum number of accounts created per hour
# from the same IP address. In a classroom, all computers often go out
# through the same address: increase if needed.
REGISTRATION_MAX_PER_HOUR=10

# captcha TEST MODE: if this variable has a value, that value is
# accepted as the answer to ANY captcha. Handy to query
# the API with curl (you cannot "read" the image in a console).
# Leave EMPTY in production.
CAPTCHA_TEST=

# -----------------------------------------------------------------------
# production
# -----------------------------------------------------------------------
# folder of the compiled web client, served by this server (default: client/)
# CLIENT_DIR=client
# set to true if the server is behind a proxy (nginx...)
TRUST_PROXY=false
  • line 8: the server listens on port 8080;
  • lines 12–16: the connection to [MySQL]. With [Laragon], the user [root] has no password: the default values are fine;
  • line 25: the secret key that signs the authentication tokens;
  • lines 38–45: anti-bot measures: after 5 failed login attempts for the same username from the same IP address (IP), that combination is blocked for 15 minutes; a single IP address (IP) cannot create more than 10 accounts per hour. During application development—when mistakes are common—you can find these values in your [.env] file;
  • line 51: the CAPTCHA test mode. The login page asks you to retype the characters from an image, which is impossible from a console. If this variable has a value, that value is accepted as the answer to any CAPTCHA, just like the “test keys” that [Google] provides for its [reCAPTCHA]. For the tests with [curl] in this chapter, enter [CAPTCHA_TEST=test]. The scripts that generated the screenshots for this course also use this value. Leave it empty in production;
  • lines 57–59: two settings for production (see the “Deployment” chapter).

4. Launch.

npm run start:dev

The console displays (excerpt; [start:dev] restarts the server every time its code is modified):

[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [NestFactory] Starting Nest application...
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +19ms
...
[Nest] 13189  - 10/06/2026, 1:29:56 PM    WARN [RdvMedecins] captcha TEST MODE active (CAPTCHA_TEST in .env): never use it in production
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [RoutesResolver] ScheduleController {/api/schedule}: +1ms
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [RouterExplorer] Mapped {/api/schedule, GET} route +1ms
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [RoutesResolver] AppointmentsController {/api/appointments}: +0ms
...
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [RouterExplorer] Mapped {/api/auth/me, GET} route +0ms
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [NestApplication] Nest application successfully started +6ms
[Nest] 13189  - 10/06/2026, 1:29:56 PM     LOG [RdvMedecins] JSON server started: http://localhost:8080/api

Each line [Mapped] announces a route from API. If the database is inaccessible ([MySQL] server stopped, incorrect password), the server stops immediately with a connection error message.

8.3. Response Format

All routes begin with [/api]. A successful request receives a 2xx status code and, in its body, the requested data in the format JSON. A rejected request receives a 4xx or 5xx status code and, in the body, data in the same format:

1
2
3
4
5
6
{
  "statusCode": 400,
  "key": "ERRORS.INVALID_FORM",
  "params": { "min": "6" },
  "fields": { "lastName": "ERRORS.LASTNAME_REQUIRED", "password": "ERRORS.PASSWORD_TOO_SHORT" }
}
  • [statusCode]: the code HTTP, repeated in the body;
  • [key]: the message translation key. The server never returns text to be displayed: it does not know the user’s language. It is the client that translates the key using its dictionaries (see Example 21);
  • [params] (optional): message parameters (“At least {{min}} characters,” “Please try again in {{minutes}} minute(s)”;
  • [fields] (optional): for a rejected form, the error for each field.

Codes used:

Code
Meaning
Examples of keys
400
Invalid request
[ERRORS.INVALID_FORM] (+ fields), [ERRORS.INVALID_DATE], [ERRORS.WRONG_CAPTCHA], [ERRORS.CHOOSE_PATIENT], [ERRORS.WRONG_CURRENT_PASSWORD], [ERRORS.BAD_REQUEST]...
401
not authenticated
[ERRORS.AUTH_REQUIRED] (no token), [ERRORS.SESSION_EXPIRED] (invalid or expired token), [LOGIN.ERROR]
403
Access Denied
[ERRORS.FORBIDDEN] (insufficient role), [ERRORS.CAPTCHA_BEFORE_ACCOUNT] (no pass)
404
Not Found
[ERRORS.NOT_FOUND], [ERRORS.DOCTOR_NOT_FOUND], [ERRORS.APPOINTMENT_NOT_FOUND]...
409
Business rule violation
[ERRORS.PAST_DATE], [ERRORS.SLOT_ALREADY_BOOKED], [ERRORS.LOGIN_TAKEN], [ERRORS.CONCURRENT_UPDATE]...
415
POST, which is not in JSON
[ERRORS.JSON_REQUIRED]
429
Too many attempts
[ERRORS.TOO_MANY_ATTEMPTS], [ERRORS.TOO_MANY_ACCOUNTS] (+ params.minutes)
500
server error
[ERRORS.SERVER_ERROR]

8.4. Authentication

The client authenticates using [POST /api/auth/login], with a username, password, and CAPTCHA response. In return, the server sends, in the [Set-Cookie] header of its response, a cookie containing a signed token JWT:

Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; Expires=Tue, 06 Oct 2026 14:12:15 GMT; HttpOnly; SameSite=Strict

The client must then include this cookie in the [Cookie] header with every request to [/api]: this is how the server knows who is making the request.

This server was designed for web clients, and its design choices are driven by the risks specific to web browsers:

  • [HttpOnly]: The [JavaScript] code on the page cannot read this cookie. A malicious script injected into the page would therefore be unable to steal the token. Consequence for the client: it cannot determine on its own whether it is logged in. It asks the server ([GET /api/auth/me]);
  • [Path=/api]: The cookie is only included in requests to the API;
  • [SameSite=Strict]: The browser only includes the cookie in requests sent to a page on this site. A malicious page on another site cannot cause the browser to act on the user’s behalf (a so-called CSRF attack);
  • [Max-Age=7200]: It expires after two hours, at the same time the token expires.

A second safeguard against CSRF attacks: the server rejects (415) any POST request whose body is not in JSON. A compromised HTML form on another site cannot send JSON.

8.4.1. What Changes for a Mobile Client

A [Ionic] app is a web app run on the phone by a “WebView” (the [Chrome] browser engine from [Android]): Does it follow the same rules as a browser? In this course, we need to distinguish between the two ways it runs.

In the computer's browser, during development ([ng serve]). The application is served by the [Angular] development server (http://localhost:8100); the JSON server is on a different port (8080). These are two different origins, and the browser would apply two problematic rules: it would not accept responses from a different origin unless authorized by the server (the “CORS” mechanism, which our server does not support), and it would not include the [SameSite=Strict] cookie in requests “from another site.” The solution is the same as in the [React] and [Vue.js] courses: a proxy. The development server forwards to the JSON server any request whose address begins with [/api] (file [proxy.conf.json], see Example 22). From the browser’s perspective, the application and API share the same origin: the browser stores the cookie and sends it back, and the application code doesn’t need to do anything.

On the phone ([Android], [Capacitor]). The page is served by [Capacitor] itself, from the address https://localhost; the JSON server is on the computer, at an address such as http://10.0.2.2:8080 (see Example 22). Again, there are two sources. The project activates the [CapacitorHttp] plugin from [Capacitor] (file [capacitor.config.ts]): the application’s HTTP requests are no longer sent by WebView but by the native code of [Android]. Consequences:

  • there is no longer a CORS check: this check is a browser rule, and the native code is not a browser (nor is [curl]);
  • the received cookies are stored by [Android] (its cookie manager, the same as that of WebView), and automatically sent back with subsequent requests to the same server. They are retained on the device: you remain logged in when you close and then reopen the app;
  • the [HttpOnly] and [SameSite] attributes do not cause any issues: the application code never reads the cookie (it asks [GET /api/auth/me] who is logged in), and the request is not sent by a page “from another site.”

This is an important difference from the [Flutter] course: a [Flutter] application does not have a browser, and had to write its own HTTP client that reads the [Set-Cookie] header in responses and writes the [Cookie] header in requests. With [Ionic], both in the browser and on the phone, the cookie is passed along without a single line of code. The server, for its part, doesn’t need to change anything: it only sees ordinary HTTP requests, which include the cookie it set.

8.5. The API

Method
Address
Roles
Sent/Response Body
GET
[/api/captcha]
public
Response: [{ id, svg }]
POST
[/api/auth/login]
public
[{ login, password, captchaId, captcha }]; response: [{ login, name, role }] + cookie
POST
[/api/auth/logout]
public
[{}] ; deletes the cookie
GET
[/api/auth/me]
logged in
response: [{ login, name, role }]
GET
[/api/schedule?doctorId=&day=]
all
Answer: the calendar (excluding doctorId: for a DOCTOR, his own)
POST
[/api/appointments]
all
[{ day, slotId, clientId }] (clientId ignored for a USER); 201
DELETE
[/api/appointments/:id]
all
A USER only cancels its own
GET
[/api/doctors]
all
the list (with login for ADMIN)
GET
[/api/clients]
ADMIN, DOCTOR
the list (with login for ADMIN)
GET
[/api/doctors/:id], [/api/clients/:id]
ADMIN
a record
POST
[/api/doctors], [/api/clients]
ADMIN
[{ title, firstName, lastName, login?, password? }]; 201
PUT
[/api/doctors/:id], [/api/clients/:id]
ADMIN
[{ title, firstName, lastName, login?, password?, version }]
DELETE
[/api/doctors/:id], [/api/clients/:id]
ADMIN
Response: [{}]
POST
[/api/register/access]
public
[{ captchaId, captcha }]; issues a token (cookie)
GET
[/api/register/access]
public
response: [{ valid }]
POST
[/api/register]
public + pass
[{ title, firstName, lastName, login, password, confirmation }]; 201
GET
[/api/my-area]
USER
Response: [{ patient, appointments }]
GET
[/api/my-account]
USER
Response: [{ title, firstName, lastName, login, version }]
PUT
[/api/my-account]
USER
[{ title, firstName, lastName, login, password?, confirmation?, currentPassword, version }]
DELETE
[/api/my-account]
USER
[{ currentPassword }]; deletes the account and its appointments

A calendar entry ([GET /api/schedule]) describes a time slot AND what the logged-in user is authorized to do with it:

1
2
3
4
{ "id": 3, "startHour": 8, "startMinute": 40, "endHour": 9, "endMinute": 0,
  "free": false,
  "patient": { "title": "Mme", "firstName": "Alice", "lastName": "DURAND" },
  "mine": false, "bookable": false, "appointmentId": 2 }
  • [free]: Is the time slot available on that day?
  • [patient]: the patient for the appointment. For a logged-in patient, this is equal to [null], except for ITS OWN (appointment): the names of other patients are never sent;
  • [mine]: This is the logged-in patient’s appointment;
  • [bookable]: Can the user book this time slot (available, not in the past, and, for a patient, no upcoming appointments)?
  • [appointmentId]: filled in if the user can cancel this appointment.

The server DECIDES; the client displays it. And even if a client were to cheat (by displaying a “Book” button when it shouldn’t), the server would double-check everything at the time of booking.

8.6. Query the API using curl

The [curl] folder on the server contains the JSON bodies of the requests in this chapter, one file per request. For example, [curl/login-admin.json]:

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

The commands are entered in a console opened on the [rdvmedecins-nestjs-json] directory, with the server running in CAPTCHA test mode ([CAPTCHA_TEST=test]) on a database that has just been (re)initialized. Some options for [curl]:

  • [-i]: also display the response headers; [-s]: without a progress bar;
  • [--json @file]: Send the contents of the file in POST, with the header [Content-Type: application/json] ([curl] 7.82 and later; with an older version, use [-H "Content-Type: application/json" -d @file]);
  • [-X PUT], [-X DELETE]: the HTTP method;
  • [-c cookies.txt]: save the received cookies to this file; [-b cookies.txt]: send them back. This is how [curl] “stays logged in” from one command to the next, just as a browser would.

Under [PowerShell], you must enter [curl.exe] and not [curl] (which refers to a different command). The following output is from an actual session (October 6, 2026, with appointments scheduled for January 2030, so that they are still in the future). The somewhat lengthy JSON responses have been formatted for this document (one line per object), while the JWT tokens and the CAPTCHA image have been truncated (“... ”), and the headers [Date], [ETag], [Connection], and [Keep-Alive] have been removed.

8.6.1. Login

First, the CAPTCHA (the image is truncated):

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

{"id":"8f711d55-566c-44e6-b02b-f114a02279f6","svg":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"150..."}

Without a token, a protected URL returns a 401 response:

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

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

An incorrect CAPTCHA, followed by an incorrect password:

1
2
3
4
5
6
$ curl -i --json @curl/login-wrong-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,"key":"ERRORS.WRONG_CAPTCHA","fields":{"captcha":"ERRORS.WRONG_CAPTCHA"}}
1
2
3
4
5
6
$ curl -i --json @curl/login-wrong-password.json http://localhost:8080/api/auth/login
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 38

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

A successful login sets the cookie, which [curl] stores in [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=Tue, 06 Oct 2026 14:12:15 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"login":"admin","name":"Administrator","role":"ADMIN"}

With this cookie, the server knows who we are:

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

{"login":"admin","name":"Administrator","role":"ADMIN"}

A forged (or expired) token results in an expired session, and the server requests that the cookie be deleted (an expired expiration date):

1
2
3
4
5
6
7
$ curl -i -b "rdvmedecins_jwt=abc.def.ghi" http://localhost:8080/api/auth/me
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,"key":"ERRORS.SESSION_EXPIRED"}

8.6.2. Doctors, clients, calendar

The list of doctors, as seen by ADMIN (with logins):

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

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

Dr. PELISSIER’s calendar for January 15, 2030:

$ curl -s -b cookies.txt "http://localhost:8080/api/schedule?doctorId=1&day=2030-01-15"
{
  "doctor":{"id":1,"version":0,"title":"Mme","firstName":"Marie","lastName":"PELISSIER"},
  "day":"2030-01-15",
  "slots":[
    {"id":1,"startHour":8,"startMinute":0,"endHour":8,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":2,"startHour":8,"startMinute":20,"endHour":8,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":3,"startHour":8,"startMinute":40,"endHour":9,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":4,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":5,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":6,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":7,"startHour":10,"startMinute":0,"endHour":10,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":8,"startHour":10,"startMinute":20,"endHour":10,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":9,"startHour":10,"startMinute":40,"endHour":11,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":10,"startHour":11,"startMinute":0,"endHour":11,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":11,"startHour":11,"startMinute":20,"endHour":11,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":12,"startHour":11,"startMinute":40,"endHour":12,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null}
  ],
  "info":null
}

A date that does not exist:

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

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

8.6.3. Appointments

ADMIN reserves slot 3 for patient No. 2 (Alice DURAND). The body of the request ([curl/appointment-admin.json]):

{ "day": "2030-01-15", "slotId": 3, "clientId": 2 }
1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/appointment-admin.json http://localhost:8080/api/appointments
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 51

{"id":2,"day":"2030-01-15","slotId":3,"clientId":2}

The calendar now displays this appointment, along with the patient’s name and instructions on how to cancel it ([appointmentId]):

$ curl -s -b cookies.txt "http://localhost:8080/api/schedule?doctorId=1&day=2030-01-15"
{
  "doctor":{"id":1,"version":0,"title":"Mme","firstName":"Marie","lastName":"PELISSIER"},
  "day":"2030-01-15",
  "slots":[
    {"id":1,"startHour":8,"startMinute":0,"endHour":8,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":2,"startHour":8,"startMinute":20,"endHour":8,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":3,"startHour":8,"startMinute":40,"endHour":9,"endMinute":0,"free":false,"patient":{"title":"Mme","firstName":"Alice","lastName":"DURAND"},"mine":false,"bookable":false,"appointmentId":2},
    {"id":4,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":5,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":6,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":7,"startHour":10,"startMinute":0,"endHour":10,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":8,"startHour":10,"startMinute":20,"endHour":10,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":9,"startHour":10,"startMinute":40,"endHour":11,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":10,"startHour":11,"startMinute":0,"endHour":11,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":11,"startHour":11,"startMinute":20,"endHour":11,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":12,"startHour":11,"startMinute":40,"endHour":12,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null}
  ],
  "info":null
}

Try again: the patient already has an upcoming appointment. An appointment in the past (January 15, 2020, file [curl/appointment-past.json]) is also rejected:

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

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

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

A standard HTML form (simulated here by [-d], which sends [application/x-www-form-urlencoded]) is rejected:

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

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

8.6.4. A patient’s perspective

You log out, then log in as [martin]:

1
2
3
4
5
6
7
$ curl -i -b cookies.txt -c cookies.txt --json @curl/empty.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","name":"Jean MARTIN","role":"USER"}

The same schedule, as seen by a patient: slot 3 is occupied (["free":false]), but ["patient"] is equivalent to [null]: Alice's name (DURAND) was not sent:

$ curl -s -b cookies-martin.txt "http://localhost:8080/api/schedule?doctorId=1&day=2030-01-15"
{
  "doctor":{"id":1,"version":0,"title":"Mme","firstName":"Marie","lastName":"PELISSIER"},
  "day":"2030-01-15",
  "slots":[
    {"id":1,"startHour":8,"startMinute":0,"endHour":8,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":2,"startHour":8,"startMinute":20,"endHour":8,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":3,"startHour":8,"startMinute":40,"endHour":9,"endMinute":0,"free":false,"patient":null,"mine":false,"bookable":false,"appointmentId":null},
    {"id":4,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":5,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":6,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":7,"startHour":10,"startMinute":0,"endHour":10,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":8,"startHour":10,"startMinute":20,"endHour":10,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":9,"startHour":10,"startMinute":40,"endHour":11,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":10,"startHour":11,"startMinute":0,"endHour":11,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":11,"startHour":11,"startMinute":20,"endHour":11,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":12,"startHour":11,"startMinute":40,"endHour":12,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null}
  ],
  "info":null
}

A patient does not have access to the client list:

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,"key":"ERRORS.FORBIDDEN"}

They are booking for themselves (the record, [curl/appointment-martin.json], does not contain [clientId]), once, not twice ([curl/appointment-martin-2.json]: the next day, slot 6):

{ "day": "2030-01-15", "slotId": 5 }
1
2
3
4
5
6
$ curl -i -b cookies-martin.txt --json @curl/appointment-martin.json http://localhost:8080/api/appointments
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 51

{"id":3,"day":"2030-01-15","slotId":5,"clientId":1}
1
2
3
4
5
6
$ curl -i -b cookies-martin.txt --json @curl/appointment-martin-2.json http://localhost:8080/api/appointments
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 55

{"statusCode":409,"key":"SCHEDULE.ALREADY_BOOKED_INFO"}

His area:

$ curl -s -b cookies-martin.txt http://localhost:8080/api/my-area
{
  "patient":{"title":"Mr","firstName":"Jean","lastName":"MARTIN"},
  "appointments":[
    {
      "id":3,
      "day":"2030-01-15",
      "slot":{"id":5,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40},
      "doctor":{"id":1,"version":0,"title":"Mme","firstName":"Marie","lastName":"PELISSIER"}
    }
  ]
}

He cannot cancel another patient’s appointment. The server responds with “not found,” just as it would for an appointment that doesn’t exist: it reveals nothing about other patients’ appointments:

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

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

His own, yes:

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

{}

8.6.5. A doctor’s perspective

Dr. PELISSIER logs in:

$ curl -s -c cookies-pelissier.txt --json @curl/login-pelissier.json http://localhost:8080/api/auth/login
{"login":"pelissier","name":"Marie PELISSIER","role":"DOCTOR"}

Without [doctorId], a logged-in doctor receives their OWN schedule, with all the names ([martin]'s appointment has just been canceled: Alice's appointment, DURAND, remains):

$ curl -s -b cookies-pelissier.txt "http://localhost:8080/api/schedule?day=2030-01-15"
{
  "doctor":{"id":1,"version":0,"title":"Mme","firstName":"Marie","lastName":"PELISSIER"},
  "day":"2030-01-15",
  "slots":[
    {"id":1,"startHour":8,"startMinute":0,"endHour":8,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":2,"startHour":8,"startMinute":20,"endHour":8,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":3,"startHour":8,"startMinute":40,"endHour":9,"endMinute":0,"free":false,"patient":{"title":"Mme","firstName":"Alice","lastName":"DURAND"},"mine":false,"bookable":false,"appointmentId":2},
    {"id":4,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":5,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":6,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":7,"startHour":10,"startMinute":0,"endHour":10,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":8,"startHour":10,"startMinute":20,"endHour":10,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":9,"startHour":10,"startMinute":40,"endHour":11,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":10,"startHour":11,"startMinute":0,"endHour":11,"endMinute":20,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":11,"startHour":11,"startMinute":20,"endHour":11,"endMinute":40,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null},
    {"id":12,"startHour":11,"startMinute":40,"endHour":12,"endMinute":0,"free":true,"patient":null,"mine":false,"bookable":true,"appointmentId":null}
  ],
  "info":null
}

It does not handle doctors:

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

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

8.6.6. Managing doctors and clients (ADMIN)

ADMIN is reconnecting (it had disconnected earlier):

$ curl -s -c cookies.txt --json @curl/login-admin.json http://localhost:8080/api/auth/login
{"login":"admin","name":"Administrator","role":"ADMIN"}

An addition. If the username is missing, the default is the name in lowercase; the password is the username ([curl/doctor-add.json]):

{ "title": "Mme", "firstName": "Claire", "lastName": "LEROY" }
1
2
3
4
5
6
$ curl -i -b cookies.txt --json @curl/doctor-add.json http://localhost:8080/api/doctors
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 90

{"id":4,"version":1,"title":"Mme","firstName":"Claire","lastName":"LEROY","login":"leroy"}

Invalid body: the response lists the error for each field ([curl/doctor-invalid.json]):

{ "title": "Dr", "firstName": "", "lastName": "A-LAST-NAME-FAR-TOO-LONG-FOR-THE-DB", "login": "Claire L" }
$ curl -i -b cookies.txt --json @curl/doctor-invalid.json http://localhost:8080/api/doctors
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 218

{
  "statusCode":400,
  "key":"ERRORS.INVALID_FORM",
  "params":{"min":"6"},
  "fields":{"login":"ERRORS.INVALID_LOGIN","title":"ERRORS.INVALID_TITLE","firstName":"ERRORS.FIRSTNAME_REQUIRED","lastName":"ERRORS.LASTNAME_TOO_LONG"}
}

A homonym (Mr. Paul TATOU, [curl/doctor-duplicate.json]): both affected fields are flagged:

$ curl -i -b cookies.txt --json @curl/doctor-duplicate.json http://localhost:8080/api/doctors
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Content-Length: 151

{
  "statusCode":409,
  "key":"ERRORS.DOCTOR_ALREADY_EXISTS",
  "fields":{"firstName":"ERRORS.DOCTOR_ALREADY_EXISTS","lastName":"ERRORS.DOCTOR_ALREADY_EXISTS"}
}

A modification. [version] is the one read along with the record (here 1, [curl/doctor-edit.json]):

{ "title": "Mme", "firstName": "Claire", "lastName": "LEROY-MARTIN", "version": 1 }
1
2
3
4
5
6
$ curl -i -b cookies.txt -X PUT --json @curl/doctor-edit.json http://localhost:8080/api/doctors/4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 97

{"id":4,"version":2,"title":"Mme","firstName":"Claire","lastName":"LEROY-MARTIN","login":"leroy"}

The record is now at version 2. Resubmitting the same change (with version 1) would overwrite a record that has since been modified by someone else: this is what the optimistic lock prevents:

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

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

A doctor with appointments cannot be deleted; the others can:

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

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

{}

8.6.7. Creating an account

Without a pass, it’s impossible:

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

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

First, you solve a CAPTCHA ([curl/captcha-test.json]: [{ "captchaId": "-", "captcha": "test" }]); the server issues a token, valid for 15 minutes, in a cookie:

1
2
3
4
5
6
7
$ curl -i -c cookies-registration.txt --json @curl/captcha-test.json http://localhost:8080/api/register/access
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_registration=d2f6290a-b277-4785-9fd9-fada3be36087; Max-Age=900; Path=/api/register; Expires=Tue, 06 Oct 2026 12:27:16 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 2

{}
$ curl -s -b cookies-registration.txt http://localhost:8080/api/register/access
{"valid":true}

The password confirmation must be identical ([curl/registration-confirmation.json]: “secret1,” then “secret2”):

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

{"statusCode":400,"key":"ERRORS.INVALID_FORM","fields":{"confirmation":"ERRORS.PASSWORDS_DIFFER"}}

The correct body ([curl/registration.json]):

{ "title": "Mme", "firstName": "Julie", "lastName": "BERNARD", "login": "jbernard", "password": "secret1", "confirmation": "secret1" }
1
2
3
4
5
6
7
$ curl -i -b cookies-registration.txt -c cookies-registration.txt --json @curl/registration.json http://localhost:8080/api/register
HTTP/1.1 201 Created
Set-Cookie: rdvmedecins_registration=; Path=/api/register; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 20

{"login":"jbernard"}

The passcode has been used: it cannot be used to create a second account:

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

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

8.6.8. Manage Your Account

The new patient logs in and reads her record:

$ curl -s -c cookies-jbernard.txt --json @curl/login-jbernard.json http://localhost:8080/api/auth/login
{"login":"jbernard","name":"Julie BERNARD","role":"USER"}
$ curl -s -b cookies-jbernard.txt http://localhost:8080/api/my-account
{"title":"Mme","firstName":"Julie","lastName":"BERNARD","login":"jbernard","version":1}

A change requires the current password. The server issues a new token, which contains the new name ([curl/my-account.json]):

{ "title": "Mme", "firstName": "Julie", "lastName": "BERNARD-DUVAL", "login": "jbernard", "currentPassword": "secret1", "version": 1 }
1
2
3
4
5
6
7
$ curl -i -b cookies-jbernard.txt -c cookies-jbernard.txt -X PUT --json @curl/my-account.json http://localhost:8080/api/my-account
HTTP/1.1 200 OK
Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; Expires=Tue, 06 Oct 2026 14:12:17 GMT; HttpOnly; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 63

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

Deleting the account ([curl/my-account-delete.json]: [{ "currentPassword": "secret1" }]) also deletes the cookie:

1
2
3
4
5
6
7
$ curl -i -b cookies-jbernard.txt -c cookies-jbernard.txt -X DELETE --json @curl/my-account-delete.json http://localhost:8080/api/my-account
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/me
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 47

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

8.6.9. Some errors

An unknown route, a body that is not a valid JSON:

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

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

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

We now know everything we need to know about the server to write our client.