7. 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 from the previous lesson, whose controllers no longer generate HTML pages but instead produce JSON responses. Its business and data access layers remain unchanged.
In this course, this server is a black box: we 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].
7.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 the 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 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.
7.2. Installation
You will need [Node.js] and a [MySQL] (or [MariaDB]) server: their installation is described in the appendix.
1. The database. The script [database/dbrdvmedecins2.sql] creates the database [dbrdvmedecins2], 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:
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 (the Angular client reaches it through the ng serve proxy)
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 Angular 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 protections (lockout after multiple failed login attempts, number of accounts created per hour);
- line 51: the CAPTCHA test mode. The login page requires users 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, similar to the “test keys” that [Google] provides for its [reCAPTCHA]. For the tests with [curl] in this chapter, enter [CAPTCHA_TEST=test]. Leave it EMPTY in production;
- lines 57–59: two settings for production (see the “Deployment” chapter).
4. Launch.
The console displays (excerpt):
| [Nest] LOG [NestFactory] Starting Nest application...
[Nest] WARN [RdvMedecins] captcha TEST MODE active (CAPTCHA_TEST in .env): never use it in production
[Nest] LOG [RoutesResolver] ScheduleController {/api/schedule}:
[Nest] LOG [RouterExplorer] Mapped {/api/schedule, GET} route
[Nest] LOG [RoutesResolver] AppointmentsController {/api/appointments}:
...
[Nest] LOG [NestApplication] Nest application successfully started
[Nest] LOG [RdvMedecins] JSON server started: http://localhost:8080/api
|
If the database is inaccessible ([MySQL] server stopped, incorrect password), the server stops immediately with a connection error message.
All URLs 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:
| {
"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 no longer knows 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], [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] |
7.4. Authentication
The client authenticates using [POST /api/auth/login], with a username, password, and CAPTCHA response. In return, the server places a cookie in the browser that contains a signed token JWT:
| Set-Cookie: rdvmedecins_jwt=eyJhbGciOiJIUzI1NiIs...; Max-Age=7200; Path=/api; HttpOnly; SameSite=Strict
|
Its attributes are important for security:
- [HttpOnly]: The page’s [JavaScript] code 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 browser only includes it in requests to API;
- [SameSite=Strict]: The browser only includes this token in requests sent FROM 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.
The browser then automatically sends this cookie with every request to [/api]. The client [Angular] therefore does not need to do anything to authenticate itself, provided that its pages and API share the same origin (same protocol, same machine, same port). This is the role of the proxy on the [Angular] development server (see Example 22), and of the server itself in production (see the “Deployment” chapter).
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.
7.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 schedule (without 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 cancels only 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 schedule entry ([GET /api/schedule]) describes a time slot AND what the logged-in user is authorized to do with it:
| { "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 THEIR OWN appointments: 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]: Set if the user can cancel this appointment.
The server DECIDES; the client displays. 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.
7.6. Query the API using curl
The [curl] folder on the server contains the JSON request bodies from this chapter, with one file per request. For example, [curl/login-admin.json]:
| { "login": "admin", "password": "admin", "captchaId": "-", "captcha": "test" }
|
Commands are entered in a console opened on the [rdvmedecins-nestjs-json] directory. 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 there). The somewhat lengthy responses for JSON have been formatted for this document, and the headers for [Date], [ETag], [Connection], and [Keep-Alive] have been removed.
7.6.1. Login
First, the CAPTCHA (the image is cropped):
| $ 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: 11304
{"id": "e33886ae-1a8b-4302-8cbc-cd7015e82f1f", "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"150..."}
|
Without a token, a protected URL returns a 401 response:
| $ 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:
| $ 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"}}
|
| $ 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]:
| $ 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", "name": "Administrator", "role": "ADMIN"}
|
With this cookie, the server knows who we are:
| $ 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 instructs the browser to delete the cookie:
| $ 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"}
|
7.6.2. Doctors, clients, schedule
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 schedule 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:
| $ 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"}
|
7.6.3. Appointments
ADMIN reserves slot 3 for patient No. 2 (Alice DURAND). The body of the request:
| { "day": "2030-01-15", "slotId": 3, "clientId": 2 }
|
| $ 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 schedule 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 is also rejected:
| $ 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"}
|
| $ 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:
| $ 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"}
|
7.6.4. A patient’s perspective
You log out, then log in as [martin]:
| $ 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 taken (["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:
| $ 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 making a reservation for themselves (the body does not contain [clientId]), once, not twice:
| { "day": "2030-01-15", "slotId": 5 }
|
| $ 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}
|
| $ 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:
| $ 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:
| $ 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
{}
|
7.6.5. A doctor’s perspective
Without [doctorId], a logged-in doctor receives their OWN schedule, with all the names:
| $ 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": false, "patient": {"title": "Mr", "firstName": "Jean", "lastName": "MARTIN"}, "mine": false, "bookable": false, "appointmentId": 3},
{"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 manage doctors:
| $ 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"}
|
7.6.6. Managing doctors and clients (ADMIN)
An addition. If the username is missing, the default is the name in lowercase; the password is the username:
| { "title": "Mme", "firstName": "Claire", "lastName": "LEROY" }
|
| $ 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:
| { "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: 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. The [version] is the one read from the record (here, 1):
| { "title": "Mme", "firstName": "Claire", "lastName": "LEROY-MARTIN", "version": 1 }
|
| $ 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: the optimistic lock prevents this:
| $ 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:
| $ 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"}
|
| $ 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
{}
|
7.6.7. Creating an account
Without a pass, it’s impossible:
| $ 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; the server issues a token, valid for 15 minutes, in a cookie:
| $ 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=73a037d1-78f2-4e8e-a3a5-b6660a4f7fd5; Max-Age=900; Path=/api/register; 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-registration.txt http://localhost:8080/api/register/access
{"valid": true}
|
The password confirmation must be identical:
| $ 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"}}
|
| { "title": "Mme", "firstName": "Julie", "lastName": "BERNARD", "login": "jbernard", "password": "secret1", "confirmation": "secret1" }
|
| $ 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:
| $ 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"}
|
7.6.8. Manage Your Account
The new patient logs in and reads her account record:
| $ curl -s -b cookies-jbernard.txt http://localhost:8080/api/my-account
{"title": "Mme", "firstName": "Julie", "lastName": "BERNARD", "login": "jbernard", "version": 1}
|
Making a change requires the current password. The server issues a new token, which contains the new name:
| { "title": "Mme", "firstName": "Julie", "lastName": "BERNARD-DUVAL", "login": "jbernard", "currentPassword": "secret1", "version": 1 }
|
| $ 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=Mon, 28 Sep 2026 12:49:13 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 also deletes the cookie:
| $ 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
{}
|
| $ 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"}
|
7.6.9. Some errors
An unknown route, a body that is not a valid JSON:
| $ 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"}
|
| $ 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.