# Building and testing REST APIs
A series of 5 article(s), in reading order.
# N'interrogez plus votre base PostgreSQL, préférez PostgREST
> Utilisez Docker et PostgREST pour transformer instantanément votre base de données PostgreSQL en une puissante API RESTful. Éliminez les requêtes SQL et les modèles de base de données complexes du code de votre application grâce à ce tutoriel de mise en place tout simple.
---

**TL;DR**
Cet article présente PostgREST, un outil qui transforme une base de données PostgreSQL directement en API RESTful, ce qui évite d'écrire des modèles et des requêtes dans le code de votre application. On y crée une base d'exemple dans Docker, on lance PostgREST dessus et on interroge l'API obtenue avec `curl` : filtres, recherche full-text, jointures, pagination, casting et formats de sortie (JSON/CSV/texte).
L'année dernière, j'avais une grosse application développée en Laravel qui nécessitait une base MySQL. En migrant vers PostgreSQL, j'ai découvert PostgREST, qui m'a permis de supprimer complètement les requêtes de mon code.
Ne vous trompez pas : mon code Laravel/PHP lançait des dizaines de requêtes vers la base et, après migration, plus aucune.
Mes tables, mes modèles, mes requêtes SQL : j'ai pu tout retirer de mon code. Mon code PHP a été considérablement allégé et simplifié.
## Ce que PostgREST fait pour vous {#what-postgrest-does-for-you}
Il y a une table `todos` dans une base PostgreSQL. Aucun contrôleur, aucun fichier de routes, aucun modèle — rien n'a été écrit. Et pourtant :
```bash
$ curl http://localhost:3000/todos | jq
```
```json
[
{
"id": 1,
"done": false,
"task": "finish tutorial 0",
"due": null
},
{
"id": 2,
"done": false,
"task": "pat self on back",
"due": null
}
]
```
Du JSON prêt à consommer, directement issu de la table, sans une seule ligne de code backend. C'est terriblement simple, non ?
## Pourquoi ça fonctionne {#why-it-works}
* **Le schéma de la base *est* l'API.** « PostgREST is a standalone web server that turns your PostgreSQL database directly into a RESTful API. The structural constraints and permission in the database determine the API endpoints and operations » ([documentation officielle](https://postgrest.org/en/)). Une table devient un endpoint, une colonne devient un champ.
* **Les permissions sont la couche de sécurité**, et elles sont là où elles doivent être : dans PostgreSQL. Une table `users` que vous n'accordez jamais au rôle anonyme n'existe tout simplement pas du point de vue de l'API.
* **Il n'y a plus rien à maintenir synchronisé.** Après ma propre migration, j'ai supprimé 100 % du code déclarant les tables et les champs, les déclarations de relations et les requêtes. Ce qui était une couche de modèles est devenu des URLs.
## À nous de jouer {#lets-play}
Pour cet article, créons un dossier temporaire dans votre dossier `/tmp` : ouvrez une console Linux et lancez `mkdir /tmp/postgrest && cd $_`.
### Étape 1 - Créer et alimenter notre base PostgreSQL {#step-1---create-and-populate-our-postgresql-database}
Nous allons créer un container Docker pour notre base PostgreSQL :
```bash
$ docker run --name tutorial -p 5433:5432
-e POSTGRES_PASSWORD=mysecretpassword
-d postgres
```
Maintenant, entrons dans notre container PostgreSQL et lançons `psql` :
```bash
$ docker exec -it tutorial psql -U postgres
```
Copiez/collez le code ci-dessous dans votre console. Cela va créer une base appelée `api` avec une table `todos` contenant deux enregistrements. Cela va aussi créer un utilisateur `web_anon` que nous utiliserons avec PostgREST pour interroger nos données :
**create\_db.sql**
```sql
create schema api;
create table api.todos (
id serial primary key,
done boolean not null default false,
task text not null,
due timestamptz
);
insert into api.todos (task) values
('finish tutorial 0'), ('pat self on back');
create role web_anon nologin;
grant usage on schema api to web_anon;
grant select on api.todos to web_anon;
create role authenticator noinherit login password 'mysecretpassword';
grant web_anon to authenticator;
```
Pour quitter la console postgres, tapez simplement `\q`.
### Étape 2 - Installer et exécuter PostgREST {#step-2---install-and-execute-postgrest}
PostgREST est un binaire, téléchargez-le en lançant :
```bash
curl -o postgrest-v14.16-linux-static-x86-64.tar.xz -L https://github.com/PostgREST/postgrest/releases/download/v14.16/postgrest-v14.16-linux-static-x86-64.tar.xz
tar xJf postgrest-v14.16-linux-static-x86-64.tar.xz && rm -f postgrest-v14.16-linux-static-x86-64.tar.xz
```
Vous avez maintenant un fichier `postgrest` dans votre dossier.
Il nous faut une configuration : créez un fichier `tutorial.conf` avec ce contenu :
**tutorial.conf**
```conf
db-uri = "postgres://authenticator:mysecretpassword@localhost:5433/postgres"
db-schemas = "api"
db-anon-role = "web_anon"
```
> **PostgREST démarre comme un service sur le port 3000 par défaut:**
>
> L'instruction `./postgrest tutorial.conf` démarre un service. Vous pouvez l'arrêter avec `CTRL`-`C` mais laissez-le tourner pour l'instant et ouvrez une nouvelle console.
>
> Ajoutez la ligne ci-dessous à votre fichier de conf si vous souhaitez utiliser un autre port ; p.ex. le port 3000 :
>
> **tutorial.conf**
>
> ```conf
> server-port = 3000
> ```
Lançons maintenant un container Docker pour PostgREST :
```bash
$ ./postgrest tutorial.conf
```
## D'autres requêtes {#more-queries}
À l'étape 1, nous avons créé et alimenté notre base PostgreSQL ; à l'étape 2, nous avons installé et configuré PostgREST pour utiliser cette base. À partir de maintenant, on peut l'utiliser directement comme n'importe quelle API.
Chaque requête est composée d'une partie fixe (l'URL de notre serveur PostgREST), à savoir `http://localhost
:
3000`, suivie de la requête elle-même. Pour récupérer le contenu d'une table, mentionnez simplement son nom — c'est l'appel `http://localhost
:
3000
/todos` montré en haut de cet article.
Pour des raisons esthétiques, j'utilise ici `| jq` (vous pouvez retirer cette partie si vous voulez). Consultez mon article [The jq utility for Linux](/blog/linux-jq) pour en savoir plus sur `jq`.
### Utiliser un filtre {#using-a-filter}
Vous pouvez utiliser des filtres en tapant p.ex. `?` suivi d'un nom de champ et d'un critère. Pour obtenir uniquement l'enregistrement dont l'`id` vaut 2, voici comment faire :
```bash
$ curl http://localhost:3000/todos?id=eq.2 | jq
```
Et si vous souhaitez faire une *recherche full text* pour retrouver un contenu à partir d'une valeur, voici comment faire :
```bash
$ curl http://localhost:3000/todos?task=fts.tutorial | jq
```
Dans l'exemple ci-dessus, on recherche tous les todos dont le champ `task` contient le mot `tutorial`.
Ci-dessous, une requête sur le champ `done` qui doit être vrai.
```bash
$ curl http://localhost:3000/todos?done=is.true | jq
```
Ci-dessous, nous demandons uniquement les champs `id` et `task` :
```bash
$ curl http://localhost:3000/todos?select=id,task | jq
```
### Depuis le frontend {#from-the-frontend}
Comme il s'agit de simples endpoints HTTP, votre frontend les consomme directement. En JavaScript, avec axios :
```js
const todos = axios.create({
baseURL: 'http://localhost:3000/todos',
headers: {
'Accept': 'application/json'
}
})
```
## Sous le capot (passez cette section si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}
### Permissions requises {#permissions-required}
En utilisant PostgREST, vous exposez vos tables et vos enregistrements via une API RESTful. Naturellement, il existe un système de permissions qui permet de définir ce qui est accessible (p.ex. une table `users` restera secrète) et ce qui peut être fait (p.ex. un utilisateur n'aura qu'un accès en lecture, un autre un accès en lecture-écriture).
### OpenAPI {#openapi}
PostgREST est conforme à [OpenAPI](https://swagger.io/specification/). Il est donc possible de documenter automatiquement ses routes avec l'image Docker [Swagger UI](https://hub.docker.com/r/swaggerapi/swagger-ui).
Cela signifie qu'en lançant `curl
http://localhost
:
3000` (l'URL de PostgREST), vous obtenez la liste de toutes les tables auxquelles vous avez accès (avec votre clé d'accès). Votre base devient ainsi ouverte au monde (encore une fois, uniquement ce que vous avez autorisé avec les bonnes permissions).
### Arrêter PostgREST {#close-postgrest}
Retournez dans la console où vous avez démarré PostgREST et appuyez sur `CTRL`-`C` pour l'arrêter.
Si vous avez démarré PostgreSQL plus haut, vous pouvez l'arrêter et le supprimer avec `docker container stop
tutorial
; docker container rm
tutorial`.
## Illustration de quelques appels {#illustration-of-some-calls}
> **Ces requêtes visent une autre base de données:**
>
> Les exemples ci-dessous viennent de mon repository de démo [PostgREST](https://github.com/cavo789/postgrest), dont la base contient les tables `citizens`, `workers`, `levels`, `translations` et `generic_profiles`. Elles ne fonctionneront **pas** sur la base `todos` construite plus haut dans cet article — voyez-les comme un catalogue de syntaxe, pas comme des commandes à copier tout de suite.
### Citizens {#citizens}
* Obtenir la liste de tous les citizens : `clear ; curl
http://127.0.0.1
:
3000
/citizens | jq`
* Uniquement le citizen ID 69 : `clear ; curl
http://127.0.0.1
:
3000
/citizens?id=eq.69 | jq`
* Uniquement le citizen ID 69, avec id, firstname et lastname : `clear ; curl
http://127.0.0.1
:
3000
/citizens?select=id,first_name,last_name&id=eq.69 | jq`
* Obtenir Jean, mais seulement celui qui parle néerlandais (language ID `2`) (c'est-à-dire un WHERE avec deux conditions) : `clear ; curl
http://127.0.0.1
:
3000
/citizens?first_name=fts.Jean&language_id=eq.2 | jq`
### Workers {#workers}
* Obtenir la liste de tous les workers, les cinq premiers : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email&limit=5 | jq`
* Obtenir la liste de tous les workers, les cinq suivants : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email&limit=5&offset=5 | jq`
* Ordre inverse, obtenir les 10 derniers : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email&limit=10&offset=0&order=id.desc | jq`
* Obtenir le worker id 15 : `clear ; curl
http://127.0.0.1
:
3000
/workers?id=eq.15 | jq` — comme on le voit, la sortie est un tableau avec un seul enregistrement
* Obtenir le worker id 15 - sans tableau : `clear ; curl
http://127.0.0.1
:
3000
/workers?id=eq.15 -H "Accept: application/vnd.pgrst.object+json" | jq` (c'est ici plus simple et plus logique pour le frontend)
### Utiliser une inner join {#using-inner-join}
* Obtenir la liste des workers avec leurs first\_name et last\_name (limité au worker ID `73`) : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email,citizens(first_name,last_name)&id=eq.73 | jq`
* Ajouter le code de langue : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email,citizens(first_name,last_name,language_id),languages(code)&id=eq.73 | jq`
### Levels {#levels}
* Obtenir la liste de tous les levels : `clear ; curl
http://127.0.0.1
:
3000
/levels | jq`
* Obtenir la liste des levels dont l'ID est supérieur à 10, uniquement ID et code : `clear ; curl
http://127.0.0.1
:
3000
/levels?select=id,code&id=gt.10 | jq`
### Translations {#translations}
* Obtenir la liste des traductions contenant le mot *Technical* : `clear ; curl
http://127.0.0.1
:
3000
/translations?value=fts.Technical | jq`
* Obtenir la liste des traductions commençant par le mot *Technical*, sans tenir compte de la casse : `clear ; curl
http://127.0.0.1
:
3000
/translations?value=ilike.technical* | jq`
### Generic profiles {#generic-profiles}
* Obtenir la liste des generic profiles actifs : `clear ; curl
http://127.0.0.1
:
3000
/generic_profiles?active=is.true | jq`
* et renommer le champ `code` en `GenericProfileCode` : `curl
http://127.0.0.1
:
3000
/generic_profiles?select=id,GenericProfileCode
:code
&active=is.true | jq`
### Format de sortie {#output-format}
* Obtenir la liste en JSON : `clear ; curl
http://127.0.0.1
:
3000
/workers -H "Accept: application/json"`
* Obtenir la liste en CSV : `clear ; curl
http://127.0.0.1
:
3000
/workers -H "Accept: text/csv"`
* Obtenir la liste en TEXT - ici, il faut ne retourner qu'une seule valeur : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=email&id=eq.15 -H "Accept: text/plain"`
### Casting {#casting}
* Obtenir l'ID et l'email du worker `15` : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id,email&id=eq.75 | jq`
* Caster l'ID en chaîne : `clear ; curl
http://127.0.0.1
:
3000
/workers?select=id::text,email&id=eq.75 | jq`
## Conclusion {#conclusion}
Un container, un binaire et un fichier `.conf`, et votre base de données répond en HTTP. Ce que je trouve remarquable, ce n'est pas la rapidité de la mise en place, c'est ce qui disparaît ensuite : les modèles, les déclarations de relations, les appels au query builder — tout un layer dont le seul rôle était de décrire, dans votre langage, quelque chose que PostgreSQL savait déjà.
Oracle a son équivalent, décrit dans [Transform an Oracle DB as OpenData using Oracle REST Data Services](/blog/docker-oracle-ords). Et pour regarder la base derrière l'API, [Adminer, pgadmin or phpmyadmin](/blog/docker-adminer-pgadmin-phpmyadmin) reste bien pratique.
---
# Utiliser Postman pour jouer avec les API
> Maîtrisez les tests d'API avec Postman. Utilisez-le comme un véritable outil de tests unitaires pour valider le statut des réponses, imposer une structure de données et éviter les régressions dans votre workflow de développement d'API.
---

**TL;DR**
Cet article montre comment utiliser Postman comme outil de tests unitaires pour les API : mise en place des environnements et des collections avec des assertions partagées, puis écriture de contrôles `pm.test()` pour les codes de statut, le temps de réponse, les en-têtes content-type et — avec, comme fil rouge, un service SOAP de validation de numéro de TVA — des assertions poussées sur la structure XML/JSON : nœuds, collections et valeurs précises.
Si vous développez votre propre API (peu importe le langage — [Python FastAPI](/blog/python-fastapi) prend environ une minute) ou si vous devez en consommer une, [Postman](https://www.postman.com/) peut se révéler très pratique.
*Postman exige un compte et stocke vos collections dans son cloud. Pour une alternative 100 % locale et compatible avec Git, voyez [Bruno - A postman-like tool - GUI and CLI](/blog/bruno).*
Appeler une API et récupérer la réponse, c'est une chose, et c'est même assez simple. Mais une fonctionnalité sympa de Postman est la possibilité de valider la réponse : s'assurer que le type retourné est par exemple `application/json`, que le code de statut HTTP est 200, que le corps de la réponse est un objet JSON (ou une chaîne XML), que ce corps contient certaines informations obligatoires, et ainsi de suite.
Vous pouvez aussi valider la réponse par rapport à un schéma donné pour vérifier que la structure est bien celle attendue.
Dans cet article, nous allons utiliser Postman comme un outil de tests unitaires, c'est-à-dire lancer des contrôles sur notre propre API et faire un maximum d'assertions. Cela améliore la qualité de votre code en mettant en évidence les erreurs potentielles et, pour tout refactoring futur, relancer les tests vous garantira que vous n'avez rien cassé ; que vous n'avez aucune régression. Être sûr de ne pas avoir cassé une API quand vous mettez du code à jour, ça vaut de l'or.
Vous pouvez télécharger Postman gratuitement ici : . Il faudra créer un compte avant de pouvoir télécharger le programme. La documentation se trouve sur ; cet article n'expliquera donc pas en détail comment utiliser le programme mais donnera simplement quelques astuces.
## Créer un environnement {#creating-an-environment}
Créer un environnement, c'est comme créer des variables globales.

Dans l'exemple ci-dessus, je définis mon `base_url` avec l'URL de mon web service.
Pensez bien à activer l'environnement (voir le point 2 sur l'image).
Dès à présent, je peux donc créer une requête et utiliser `{{base_url}}`

## Créer une collection {#creating-a-collection}
Si vous devez créer plus d'une requête, mieux vaut créer une collection (comprenez *un projet*). Vous pourrez y stocker toutes vos requêtes, mais le plus gros avantage est de pouvoir définir des règles par défaut comme, par exemple, des tests à lancer pour chaque requête :

Ainsi, quelle que soit la requête que je lance, les quatre tests ci-dessous seront toujours exécutés :
```php
pm.test("response is ok", function () {
pm.response.to.have.status(200);
});
pm.test("Content-Type header is present", () => {
pm.response.to.have.header("Content-Type");
});
pm.test("Content-Type header is text/xml", () => {
pm.expect(pm.response.headers.get('Content-Type')).to.include('text/xml; charset=utf-8');
});
pm.test("Don't contain any error", function ()
{
pm.expect(pm.response.text()).to.not.include("error");
});
```
## Créer une requête {#creating-a-request}
En créant une nouvelle requête, pour passer des informations dans l'en-tête, il me suffit de cliquer sur l'onglet `Headers` puis de renseigner la clé que je souhaite envoyer. Dans l'exemple d'une requête SOAP (c'est-à-dire appelée avec une *enveloppe XML*), je devrai envoyer une clé `SOAPAction` avec le nom de l'action à lancer (`testFlag` ici) et je préciserai que le corps envoyé est de type `application/xml`.

Ensuite, puisque cet exemple concerne une requête SOAP, je dois envoyer un corps XML, tel qu'attendu par l'action :

```xml
```
Le placeholder `{{wsdl}}` est une variable définie dans l'environnement.
Et, en option, nous pouvons aussi ajouter un test spécifique à la requête :

```php
pm.test("status OK", function () {
var jsonObject = xml2Json(responseBody);
var status = jsonObject["SOAP-ENV:Envelope"]["SOAP-ENV:Body"]["ns1:testFlagOutput"]["status"];
return status == "OK"
});
```
> **Remplacez `testFlag` par le nom de votre nœud:**
En lançant la requête, dans cet exemple, nous exécuterons cinq tests puisque nous avons déjà défini quatre tests pour la collection.

## Exporter / importer une collection {#exporting--importing-a-collection}
En regroupant toutes vos requêtes dans une collection, vous pouvez facilement l'exporter (sous forme de fichier `.json`)

Et, bien sûr, l'importer presque de la même manière :

## Exemple concret {#real-world-example}
Comme vu dans l'article [MS Excel - How to call a SOAP web service](/blog/vba-excel-call-soap-webservice), nous pouvons appeler un web service européen pour vérifier la validité d'un numéro de TVA.
Essayons avec Postman :
L'URL de base du service est `http://ec.europa.eu/taxation_customs/vies/services/checkVatService`, la méthode doit être `POST` et nous devons définir l'en-tête `SOAPAction` à `checkVAT`.

Ensuite, nous devons envoyer un corps XML spécifique. Pour l'exemple, nous allons interroger un numéro de TVA en Belgique.
```xml
BE
0403170701
```

En lançant la requête, Postman renvoie :
```xml
BE
0403170701
2024-03-05+01:00
true
SA ELECTRABEL
Boulevard Simon Bolivar 36 1000 Bruxelles
```

## Quelques contrôles {#some-checks}
> **J'utilise `{{wsdl}}` comme placeholder:**
>
> Dans les exemples ci-dessous, vous verrez `{{wsdl}}` dans les extraits de code XML. Ce n'est qu'un placeholder : en situation normale, vous y retrouverez une URL vers un service WSDL (ou une API REST).
### Contrôler des métriques comme le responseTime {#check-some-metrics-like-the-responsetime}
Si vous souhaitez valider le temps de réponse d'une requête, par exemple 500 ms :
```php
pm.test('Response time is within an acceptable range', function () {
pm.expect(pm.response.responseTime).to.be.below(500);
})
```
### Contrôler le code de statut HTTP {#controls-the-http-status-code}
Valider que le code de statut de la réponse est `200` :
```php
pm.test('Response status code is 200', function () {
pm.expect(pm.response.code).to.equal(200);
})
```
### S'assurer que la réponse renvoie un content-type {#ensure-the-response-returns-a-content-type}
```php
pm.test("Content-Type header is present", () => {
pm.response.to.have.header("Content-Type");
});
```
Et si nous voulons vérifier la valeur du content-type retourné :
```php
pm.test("Content-Type header is text/xml", () => {
pm.expect(pm.response.headers.get('Content-Type')).to.include('text/xml; charset=utf-8');
});
```
### Vérifier que la réponse est une chaîne XML valide {#assert-the-response-is-a-valid-xml-string}
Test simple pour s'assurer que nous avons bien reçu une réponse XML valide :
```php
pm.test('Response body is in valid XML format', function () {
const responseData = pm.response.text();
try {
xml2Json(responseData);
pm.expect(true).to.be.true;
} catch (error) {
pm.expect.fail('Response body is not in valid XML format');
}
})
```
### Valider l'absence ou la présence de certains mots {#validate-the-absence-or-presence-of-some-words}
Analyser la réponse **comme une chaîne** et s'assurer que le mot *error* n'est pas présent :
```php
pm.test("Don't contain any error", function ()
{
pm.expect(pm.response.text()).to.not.include("error");
});
```
Cet exemple échouera donc dès que le mot *error* apparaît dans la réponse retournée.
Ou, à l'inverse, s'assurer que certains mots sont bien présents dans la réponse :
```php
pm.test("Assert 'is successful'", function ()
{
pm.expect(pm.response.text()).to.include("is successful");
});
```
### Faire des assertions sur l'absence de nœuds {#make-assertions-on-the-absence-of-nodes}
Cette assertion est plus spécifique. Elle échouera dès qu'un nœud `` est présent dans la réponse.
```php
pm.test("The error node shouldn't be part of the response", function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:testOutput']['error']).to.not.exist;
})
```
Si votre réponse est celle ci-dessous, l'assertion échouera puisque `` contient un nœud ``.
```xml
```
### Faire des assertions sur la présence de nœuds {#make-assertions-on-the-presence-of-nodes}
À titre d'exemple, nous allons nous assurer que la réponse contient toujours `` et `` :
```xml
```
```php
pm.test('Validate SOAP-ENV:Envelope and SOAP-ENV:Body elements are present', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData).to.have.property('SOAP-ENV:Envelope');
pm.expect(responseData['SOAP-ENV:Envelope']).to.exist;
pm.expect(responseData['SOAP-ENV:Envelope']).to.have.property('SOAP-ENV:Body');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']).to.exist;
})
```
> **`to.exist` est équivalent à `to.have.property`:**
`pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']).to.exist` peut aussi s'écrire comme ceci : `pm.expect(responseData['SOAP-ENV:Envelope']).to.have.property('SOAP-ENV:Body');`
### Pour du XML, vérifier la valeur d'un nœud donné {#for-xml-check-the-value-of-a-given-node}
Si vous souhaitez vous assurer qu'un nœud donné possède une propriété spécifique :
```php
pm.test('Response body has the required fields', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.have.property('SOAP-ENV:Envelope');
pm.expect(responseData['SOAP-ENV:Envelope']).to.have.property('SOAP-ENV:Body');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']).to.have.property('ns1:testFlag');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:testFlag']).to.have.property('count');
pm.expect(responseData['SOAP-ENV:Body']['ns1:getSessionsLanguagesOutput'].list).to.exist;
})
```
#### Assertions sur les collections {#assertions-for-collections}
Imaginons que votre réponse retourne une collection comme, ci-dessous, une liste de langues :
```xml
English
en
E
Français
fr
F
Nederlands
nl
N
```
Assurez-vous que le nœud list existe :
```php
pm.test('List array contains at least one element', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']).to.have.property('list');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']['list']).to.exist;
})
```
Vous voulez vous assurer qu'il y a au moins un enregistrement :
```php
pm.test('List array contains at least one element', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput'].list).to.be.an('array').and.to.have.lengthOf.at.least(1);
})
```
Vérifier que chaque élément de la liste contient bien les nœuds attendus ; par exemple :
```php
pm.test('Validate expected structure', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']).to.have.property('list');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput'].list).to.be.an('array').and.to.have.lengthOf.at.least(1);
responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']['list'].forEach(function (node) {
pm.expect(node.description).to.exist.and.to.not.be.empty;
pm.expect(node.iso).to.exist.and.to.not.be.empty;
pm.expect(node.language).to.exist.and.to.not.be.empty;
});
})
```
Quand le `
` ne contient qu'un seul élément (et donc pas un tableau) :
```php
pm.test('Validate expected structure', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']).to.have.property('list');
const list = responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getLanguagesOutput']['list']
pm.expect(list).to.have.property('description').and.to.not.be.empty;;
pm.expect(list).to.have.property('iso').and.to.not.be.empty;;
pm.expect(list).to.have.property('language').and.to.not.be.empty;;
})
```
#### Assertions sur les valeurs {#assertions-on-value}
##### Vérifier la valeur {#check-on-the-value}
Imaginez que vous ayez quelque chose comme ceci et que vous vouliez vérifier que le statut est `OK` :
```xml
OK
```
```php
pm.test("status OK", function () {
var jsonObject = xml2Json(responseBody);
var status = jsonObject["SOAP-ENV:Envelope"]["SOAP-ENV:Body"]["ns1:testFlagOutput"]["status"];
return status == "OK"
});
```
Même idée, mais nous allons nous assurer que la propriété `count` est correctement définie, c'est-à-dire avec un nombre.
```php
pm.test("Returned count is a number", function () {
var jsonObject = xml2Json(responseBody);
var count = jsonObject["SOAP-ENV:Envelope"]["SOAP-ENV:Body"]["ns1:testFlagOutput"]["count"];
pm.expect(Number.isInteger(count));
});
```
Si vous avez un nœud error dans votre réponse, assurez-vous que la propriété du code d'erreur n'est pas vide :
```php
pm.test('Error code is not empty', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:testFlag'].error.code).to.exist.and.to.not.be.empty;
})
```
Et si vous avez aussi une propriété contenant le message d'erreur, vérifiez qu'elle n'est pas vide mais contient bien une description de l'erreur.
```php
pm.test('Error message is not empty', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:testFlag']['error']['message']).to.exist.and.to.not.be.empty;
})
```
##### Contrôles sur une valeur de type date {#checks-on-date-value}
Vous avez une liste d'employés dans une collection et vous souhaitez vérifier le birthDate :
```php
pm.test('Validate birthDate is null or in a valid date format', function () {
const responseData = xml2Json(pm.response.text());
pm.expect(responseData).to.be.an('object');
pm.expect(responseData['SOAP-ENV:Envelope']['SOAP-ENV:Body']['ns1:getEmployeesOutput']['list']['birthDate']).to.satisfy(function (date) {
return date === null || new Date(date).toString() !== 'Invalid Date';
});
})
```
---
# Python - Fast API - Créez votre API JSON en Python en une minute
> Construisez votre première API JSON Python avec FastAPI en moins d'une minute ! Ce tutoriel couvre la configuration Docker, la documentation d'API automatisée (Swagger/ReDoc) et le hot-reload pour développer rapidement.
---

**TL;DR**
Créez votre première API JSON Python avec FastAPI en une minute seulement. Ce tutoriel vous guide dans la mise en place d'une API basique avec Docker, depuis un simple « Hello World » jusqu'à un générateur de blagues interactif. Vous découvrirez les fonctionnalités puissantes de FastAPI comme la documentation d'API interactive générée automatiquement (Swagger et ReDoc) et comment configurer un environnement de développement avec hot-reload pour des cycles de développement rapides.
> TLDR : une minute, c'est le temps qu'il vous faudra pour copier/coller le contenu de deux fichiers et lancer une commande Docker.
Ça paraît dingue, mais c'est VRAI. Il vous faudra juste une minute pour créer l'exemple ci-dessous : créer le répertoire de votre projet, créer deux fichiers et lancer deux commandes Docker et ... c'est fini.
En tant que programmeur PHP, quand j'ai pris le temps de lire un article de blog sur FastAPI, j'ai pensé **Nodidju ! Ç' n'est nén pussibe** (*Bon sang ! Ce n'est pas possible*).
Avec seulement deux fichiers, nous allons construire notre propre image Docker avec Python et FastAPI installés et coder notre application REST API. Pas plus de deux !
Impossible de ne pas essayer tout de suite et ... wow ... c'est VRAI !
Voici le résultat : rendez-vous sur `http://127.0.0.1:`82 et obtenez votre première réponse JSON.
> Screenshot — http\://127.0.0.1:82
>
> Hello World
## Pourquoi ça fonctionne {#why-it-works}
* `main.py` définit une route (`/`) avec deux lignes de Python — FastAPI transforme cette fonction en endpoint HTTP fonctionnel, sans aucune configuration de serveur à écrire.
* Le `Dockerfile` regroupe Python, FastAPI et votre code dans une seule image d'environ 184 Mo, donc il n'y a rien à installer sur votre host à part Docker lui-même.
## Créer notre première API {#creating-our-first-api}
Comme toujours, nous allons construire un exemple complètement fonctionnel. Ça ne pourrait pas être plus simple ; vous allez voir.
Créez un dossier bidon et placez-vous dedans :
```bash
$ mkdir /tmp/fastapi && cd $_
```
Dans ce dossier, créez un nouveau fichier appelé `Dockerfile` avec le contenu suivant :
**Dockerfile**
```docker
# We'll use the latest version of Python and the smaller image in size (i.e. `slim`)
FROM python:slim
# We'll define the default folder in the image to /app
WORKDIR /app
# The only dependency we need is fastapi
RUN pip install --no-cache-dir 'fastapi[standard]'
# We need to copy our Python script in the image
COPY main.py main.py
# And we'll run FastAPI and call our script. We'll expose the script on port 82
CMD ["fastapi", "run", "main.py", "--port", "82"]
```
La taille finale de l'image sera d'environ 184 Mo, c'est-à-dire presque rien.
Comme vous l'avez vu, il nous faut un fichier appelé `main.py` ; créons-le :
**main.py**
```python
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
```
Ce code très simple définit une route appelée `/`. C'est une méthode `GET` et nous allons retourner un objet JSON :
```json
{
"Hello": "World"
}
```
Vous savez quoi ? C'est déjà terminé.
## Lancer notre API {#running-our-api}
Il nous faut construire notre image Docker et la lancer :
```bash
$ docker build -t python-fastapi . && docker run -p 82:82 python-fastapi
```
Une fois fait, rendez-vous sur `http://127.0.0.1
:
82` et vous obtiendrez la même réponse JSON que celle montrée en haut de cet article ; dingue non ?
## Documentation automatisée de votre API {#automated-documentation-of-your-api}
Et ce n'est que le début : FastAPI fournit une API auto-documentée basée sur le schéma OpenAPI. *Ce schéma n'est pas seulement de la documentation : il peut aussi être linté, voir [Validate your OpenAPI schema against the Belgif REST standards](/blog/belgif-api-linter).*
Rendez-vous sur `http://127.0.0.1
:
82
/docs` et vous le verrez en action :
> Screenshot — http\://127.0.0.1:82/docs
>
> Documentation automatisée - Swagger UI
Il existe un second template, alternatif, appelé ReDoc. Vous pouvez y accéder via l'endpoint `redoc`, c'est-à-dire `http://127.0.0.1
:
82
/redoc` :
> Screenshot — http\://127.0.0.1:82/redoc
>
> Documentation automatisée - Redoc
## Amusons-nous - Créer un générateur de blagues {#lets-play---creating-a-joke-generator}
Nous allons construire un script générateur de blagues. Notre objectif : obtenir une blague au hasard ou une blague précise (du genre *Donne-moi la troisième blague que tu connais*).
Pour cela, nous allons mettre à jour notre script `main.py` et, comme nous ferons probablement plus d'une modification, nous allons monter notre dossier de l'host dans le container.
Pourquoi ? [Monter notre dossier](/blog/docker-volume) dans le container nous permettra de modifier le script et de simplement rafraîchir la page web pour voir le changement.
La seule chose à faire est de lancer notre container comme ceci : `docker run -v .:/app -p
82
:82 python-fastapi` ... mais ça n'a pas fonctionné comme prévu.
> **FastAPI utilise Uvicorn sous le capot:**
>
> Uvicorn est une implémentation de serveur web pour Python. Uvicorn possède un mécanisme de cache intégré donc, même si nous avons mis à jour la source de notre script `main.py`, nous obtiendrons encore l'ancienne version.
>
> Nous devons démarrer Uvicorn avec un mécanisme de « hot reload ».
### Recréer l'image Docker pour le hot reload (passez cette étape si `docker run -v .:/app` rafraîchissait déjà chez vous) {#recreating-the-docker-image-for-hot-reload-skip-this-if-docker-run--v-app-already-refreshed-for-you}
Arrêtez d'abord le container en cours d'exécution : retournez dans votre console et appuyez sur `CTRL`+`C` pour arrêter le container. Nous allons aussi supprimer le container. Vous pouvez le faire depuis `Docker Desktop` : cliquez sur le menu `Containers` et tuez votre container *Python - Fastapi*. Supprimez aussi l'image appelée `python-fastapi`.
> **Note:**
>
> `python-fastapi` est le nom de l'image Docker que nous avons construite plus tôt.
Si vous préférez la ligne de commande, vous pouvez obtenir le même résultat en lançant ces deux commandes :
```bash
$ docker rm $(docker ps -aq --filter "ancestor=python-fastapi")
$ docker rmi python-fastapi --force
```
Maintenant, copiez/collez le contenu suivant dans votre `Dockerfile` existant :
**Dockerfile**
```docker
# We'll use the latest version of Python and the smaller image in size (i.e. `slim`)
FROM python:slim
# We'll define the default folder in the image to /app
WORKDIR /app
# The only dependency we need is fastapi
RUN pip install --no-cache-dir 'fastapi[standard]'
# We need to copy our Python script in the image
COPY main.py main.py
// highlight-start
# Run Uvicorn with hot reload so any changes to our main.py script will force
# the server to invalidate the cache and refresh the page.
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "82"]
// highlight-end
```
Reconstruisez l'image et lancez un nouveau container, cette fois avec un volume, en exécutant ces commandes :
```bash
$ docker build -t python-fastapi . && docker run -v .:/app -p 82:82 python-fastapi
```
Nous avons maintenant une image Docker avec hot reload et nous avons monté notre dossier dans le container.
Modifiez votre script `main.py` comme ceci :
**main.py**
```python
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
# highlight-next-line
return {"Hello": "Belgium!"}
```
Rafraîchissez votre page web, vous verrez Hello Belgium!. Modifiez votre `main.py` et remplacez `Belgium!` par `France`. Rechargez votre page web ; vous verrez Hello France. Super, nous avons un hot reload et nous pouvons vraiment commencer à jouer.
### Notre générateur de blagues {#our-joke-generator}
Nous allons mettre à jour notre script `main.py` comme ceci :
**main.py**
```python
// highlight-next-line
from random import choice
from fastapi import FastAPI
app = FastAPI()
// highlight-start
jokes = [
"Why do programmers always mix up Halloween and Christmas? Because Oct 31 == Dec 25",
"Why don't programmers like nature? Because it's full of bugs!",
"How many programmers does it take to change a light bulb? None. That's a hardware problem.",
"What do you call 8 hobbits? A hobbyte",
"What is this [“hip”, ”hip”]? hip hip array!"
]
@app.get("/jokes")
async def get_jokes():
"""
Returns a random joke from the list.
"""
return {"joke": choice(jokes)}
@app.get("/jokes/{joke_id}")
def read_item(joke_id: int):
"""
Returns a specific joke (between 0 and 4).
"""
try:
return {"joke": jokes[joke_id]}
except IndexError:
return {"error": f"Joke with ID {joke_id} not found."}, 404
// highlight-end
```
Vous le voyez immédiatement, je pense :
* j'ai défini un tableau avec cinq blagues, codées en dur ;
* j'ai défini une nouvelle route appelée `/jokes` qui affichera une blague au hasard ;
* et enfin j'ai défini une route `jokes/{joke_id}` pour pouvoir viser une blague précise (comme « Donne-moi la deuxième blague que tu connais »).
> **Utilisez un fichier externe plutôt que des blagues codées en dur:**
>
> En guise d'exercice, supprimez simplement la partie initialisation du tableau `jokes` et lisez plutôt les blagues depuis un fichier texte. Ce serait vraiment facile à faire.
Retournez dans votre navigateur et rendez-vous sur l'endpoint `jokes` (`http://127.0.0.1
:
82
/jokes`) et, hop, vous avez une blague au hasard.
> Screenshot — http\://127.0.0.1:82/jokes
>
> Obtenir une blague au hasard
Rafraîchissez la page ; encore et encore. Chaque fois vous obtiendrez une blague au hasard (parmi une liste de 5).
Si vous en voulez une précise, ajoutez simplement un ID après, comme `http://127.0.0.1
:
82
/jokes/1`
> Screenshot — http\://127.0.0.1:82/jokes/1
>
> Une blague précise
> **Note:**
>
> Notez que le tableau commence à la position 0 donc la première blague est celle-ci : `http://127.0.0.1
> :
> 82
> /jokes/0`.
#### Nos endpoints jokes sont documentés automatiquement {#our-jokes-endpoints-are-documented-automatically}
Et en revenant à la documentation (`http://127.0.0.1
:
82
/docs`), nous avons maintenant trois routes et, regardez, la docstring Python est utilisée pour décrire la route.
> Screenshot — http\://127.0.0.1:82/docs
>
> Swagger UI - Avec les nouvelles routes
Vraiment, vraiment impressionnant !
> **La documentation est interactive:**
>
> Et, encore plus impressionnant, vous pouvez jouer directement depuis la documentation, c'est-à-dire exécuter l'endpoint `jokes`. Il y a un bouton *Try it out* puis *Execute* pour voir l'endpoint en action.
>
> Plus d'infos
## Découvrir FastAPI {#discover-fastapi}
N'attendez plus, allez sur pour voir plus d'exemples.
Et avant de publier votre API au monde entier, prenez cinq minutes pour lire [API REST - How to write good APIs](/blog/php-api-tips) : nommer correctement vos endpoints et choisir correctement vos codes de statut HTTP ne coûte rien le premier jour et coûte très cher le centième.
Lisez aussi le long tutoriel sur [Real Python - Using FastAPI to Build Python Web APIs](https://realpython.com/fastapi-python-web-apis/).
---
# API REST - Comment écrire de bonnes API
> Maîtrisez les bonnes pratiques des API REST pour une conception propre et évolutive. Conseils sur la notation correcte des URI, l'usage de noms au pluriel et l'utilisation des verbes HTTP pour des endpoints sémantiquement corrects.
---

**TL;DR**
Ce guide complet couvre les bonnes pratiques essentielles pour concevoir des API REST propres, évolutives et sémantiquement correctes. Découvrez ce qu'il faut faire et ne pas faire dans la construction des URI, y compris l'usage de noms au pluriel et l'absence de verbes. L'article détaille comment utiliser correctement les verbes HTTP comme `GET`, `POST`, `PUT`, `PATCH` et `DELETE`, et quels codes de statut HTTP retourner selon les situations. Il aborde aussi la structuration des réponses JSON pour les données, les erreurs et la pagination, en suivant les standards établis pour une conception d'API robuste.
Quand on [développe des API](/blog/python-fastapi) *from scratch*, on peut le faire en mode *je me mets devant le PC et je commence à programmer*, ou *je commence par apprendre les standards et je programme ensuite en respectant [ces standards](/blog/belgif-api-linter)*.
Et si on perd ces normes de vue, on revient vite à écrire des endpoints comme `/articles/insert`, sémantiquement incorrects (et même stupides). En fait, ici, on ne fera jamais un `GET`.
L'objectif de l'article ci-dessous est de lister les bonnes pratiques à l'aide d'exemples.
## Concepts principaux {#main-concepts}
### Uniform Resource Identifier {#uniform-resource-identifier}
#### Notation des URI {#uri-notation}
*Les segments de path et les paramètres de query devraient utiliser la notation lowerCamelCase pour améliorer la lisibilité et séparer les noms composés.* ([référence](https://www.belgif.be/specification/rest/api-guide/#resource-uri))
| BON | MAUVAIS |
| ----------------------------------------- | ------------------------------------------ |
| `https://xxx/api/v1/ipsoLorem/331` | `https://xxx/api/v1/Ipso_Lorem/331` |
| | `https://xxx/api/v1/ipso-lorem/331` |
| `https://xxx/api/v1/ipsoLorem/331` | `https://xxx/api/v1/ipsoLorem/331/` |
| `https://xxx/api/v1/ipsoLorem?country=BE` | `https://xxx/api/v1/Ipso_Lorem?Country=BE` |
Une URI ne doit pas se terminer par un `/` ni par une extension de fichier : donc pas de `https://xxx/api/v1/ipso-lorem/331.json` mais utilisez simplement des en-têtes HTTP comme `Content-Type: application/json;charset=UTF-8` pour signaler qu'il s'agit d'une réponse JSON.
#### Préférez le nom au pluriel {#prefer-plural-noun}
Utilisez toujours le pluriel, comme `GET /api/v1/employees/1` pour retourner le premier employé, et pas `GET /api/v1/employee/1` ([référence](https://www.belgif.be/specification/rest/api-guide/#collection)).
Le pluriel fait comprendre plus facilement que `employees` est une collection, et on peut utiliser des verbes comme `GET` pour tout récupérer, un seul élément ou une plage, `POST` pour ajouter un nouvel employé, `PUT` pour mettre à jour les informations d'un employé, ...
De plus, avec la forme plurielle, l'utilisateur comprend immédiatement qu'il pourra ajouter des filtres comme `/1` pour obtenir, par exemple, l'employé #1.
##### Relations entre collections {#relations-between-collections}
Quand vous construisez une URI, pensez à la hiérarchie et à la relation entre les collections. Si vous devez retourner la liste des tâches de John, vous pouvez utiliser plusieurs notations, comme illustré ci-dessous :
| BON | MAUVAIS |
| ------------------------------ | ------------------------------ |
| `/api/v1/employees/john/tasks` | `/api/v1/tasks/employees/john` |
À propos de la hiérarchie : est-ce que *les tâches ont des employés* ou est-ce que *les employés ont des tâches* ? Cette hiérarchie étant clairement définie (un employé peut avoir plusieurs tâches), placez les collections dans cet ordre dans l'URI : `/api/v1/employees/john` retournera l'employé et en ajoutant `/tasks` on obtiendra sa liste de tâches (ensuite on peut filtrer vers une tâche précise avec `/api/v1/employees/john/tasks/1` par exemple).
Cela rend les URI plus lisibles, plus intuitives et plus évolutives, comme dans `/api/v1/employees/john/projects`.
#### N'utilisez pas de verbes dans les URL {#dont-use-verbs-in-urls}
HTTP dispose de verbes comme `GET`, `POST`, `DELETE`, ... donc n'utilisez pas des appels comme :
* `/api/v1/articles/gettitle`
* `/api/v1/articles/insert`
* `/api/v1/articles/1/delete`
Utilisez plutôt le verbe HTTP adéquat :
* `GET /api/v1/articles/title`
* `PUT /api/v1/articles`
* `DELETE /api/v1/articles/1`
#### Envoyez l'en-tête HTTP Accept {#send-the-http-accept-header}
Il est **vraiment recommandé** d'informer le serveur de ce que vous attendez : `json`, `csv`, `plain-text`, `xml`, ... Pour cela, utilisez l'en-tête `Accept` comme dans l'exemple ci-dessus. C'est plus sûr, car le serveur peut changer son format par défaut de JSON vers CSV par exemple, et si vous attendez du JSON, votre code sera cassé.
Ne faites pas ceci :
```js
const employee = axios.create({
baseURL: "https://xxx/api/v1/employees/123",
});
```
Mais plutôt :
```js
const employee = axios.create({
baseURL: "https://xxx/api/v1/employees/123",
headers: {
Accept: "application/json",
},
});
```
Dans ce second exemple, vous indiquez au serveur web que vous voulez une représentation JSON et rien d'autre.
#### Filtrage {#filtering}
Utilisez `?` suivi d'une paire clé=valeur comme `?country=BE`. Si vous avez plusieurs paramètres, utilisez `&` comme dans `?country=BE&lang=FR`.
Si vous devez fournir plusieurs valeurs pour le même paramètre, le standard est de répéter le paramètre, comme dans `/cars?color=black&color=blue` ([référence](https://www.belgif.be/specification/rest/api-guide/#rule-qry-multi)).
#### Tri {#sorting}
Une pratique courante consiste à utiliser une clé appelée `sort`, qui peut être utilisée autant de fois qu'il y a de colonnes à filtrer. L'ordre de tri décroissant peut être indiqué en préfixant le nom de la propriété par un tiret `-` ([référence](https://www.belgif.be/specification/rest/api-guide/#rule-col-sort)).
Par exemple, `?sort=name&sort=-age` fera un tri croissant sur la colonne du nom et un tri décroissant sur l'âge.
#### Paramètres de query pour la pagination {#pagination-query-parameters}
Avec la pagination, `page` sert à déterminer la page courante tandis que `pageSize` indique le nombre d'enregistrements à afficher.
Ainsi, `/api/v1/employees?page=1&pageSize=5` affichera les cinq premiers employés et `/api/v1/employees?page=2&pageSize=5` les cinq suivants.
#### Noms de paramètres de query réservés {#reserved-query-parameters-name}
([référence](https://www.belgif.be/specification/rest/api-guide/#query-parameters-2))
| Terme | Description | Exemple | Réf |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------- |
| `page` | Quand une collection de ressources est paginée, utilisez ce paramètre pour demander une page précise. La numérotation des pages commence à 1. | `?page=3&pageSize=20` | Pagination |
| `pageSize` | Quand une collection de ressources est paginée, utilisez ce paramètre pour spécifier la taille de la page. | `?page=3&pageSize=20` | Pagination |
| `q` | Le paramètre de recherche standard pour faire une recherche full-text. | `?q=RESTFull` | Filtrage |
| `select` | Limite les propriétés de la ressource à celles spécifiées. | `?select=(name,address)` | Consultation (Document) |
| `sort` | Paramètre de query multi-valeurs avec la liste des propriétés sur lesquelles trier. Le sens de tri par défaut est croissant. Pour indiquer un tri décroissant, la propriété peut être préfixée par -. | `?sort=name&&sort=-age` | Collection |
| `embed` | Demande d'intégrer une sous-ressource. Par exemple, `api/v1/countries` retourne les pays et `api/v1/countries?embed=political` les pays avec leurs infos politiques. Cela évite des appels API supplémentaires. | `?embed=mainAddress` | Intégration de ressources |
| `lang` | langue pour filtrer les descriptions multilingues | `?lang=fr` | Descriptions multilingues |
### Verbes {#verbs}
#### HEAD {#head}
`HEAD` doit être utilisé pour récupérer des informations sur la ressource, comme par exemple le nombre d'enregistrements dans la ressource (nombre de livres, d'articles, ...) ou pour vérifier si une ressource donnée existe. `HEAD` devrait servir uniquement à récupérer les en-têtes de la réponse HTTP d'une ressource ([référence](https://www.belgif.be/specification/rest/api-guide/#head)).
Par exemple, imaginez une ressource appelée `countries` : on peut vérifier si `language=FR` va retourner quelque chose :
```bash
$ curl --head https://xxx/api/v1/countries?language=FR HTTP/2 200 OK
Content-Range: 0-5/* Content-Location: /api/v1/countries?language=FR
Content-Type: application/json; charset=utf-8
```
##### HEAD - Code retourné {#head---returned-code}
Si on obtient le code de retour HTTP `200`, on peut lancer une action `GET` pour obtenir les enregistrements :
```bash
$ curl
https://xxx/api/v1/countries?language=FR
| jq
```
`HEAD` peut donc servir à obtenir des métadonnées sur une ressource.
> **Combien de pays ont le français comme langue officielle ?:**
>
> Dans les en-têtes retournés par `HEAD`, nous obtiendrons très probablement une entrée `Content-Range`. Dans notre exemple fictif, on peut déterminer qu'il y a six pays où le français est une langue officielle.
| Code | Statut | Raison |
| ----- | --------------- | ------------------------------------------------------------------------------------------- |
| `200` | `OK` | Le serveur a traité la requête avec succès. Cela signifie que la ressource demandée existe. |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à accéder à la ressource demandée. |
| `404` | `Not found` | La ressource demandée n'existe pas |
Le code `204 - No Content` n'est pas attendu car, même si `HEAD` ne retourne pas de corps de réponse, le serveur retourne bien des en-têtes HTTP, ce qui est équivalent à une réponse. Le code standard est donc `200`, pas `204`.
##### Compter le nombre d'éléments dans une ressource {#count-number-of-items-in-a-resource}
>
`HEAD` peut être utilisé sur une ressource pour obtenir le nombre total d'enregistrements ; par exemple pour pouvoir afficher une barre de navigation.
Donc, ne créez pas un endpoint comme `https://xxx/api/v1/countries/count` alors que la manière standard est d'utiliser `HEAD`.
Imaginez une table de base de données appelée `countries` contenant `984` enregistrements (c.-à-d. le résultat de `SELECT Count(*) FROM countries;`).
Cette info peut être récupérée depuis notre API comme ceci :
```bash
$ curl --head https://xxx/api/v1/countries HTTP/2 200 OK Content-Range:
0-983/* Content-Location: /api/v1/countries Content-Type: application/json;
charset=utf-8
```
`curl --head` (similaire à `curl -i`) utilise le verbe HTTP `HEAD`.
Pour extraire cette information en JavaScript, le script [parse-content-range-header](https://github.com/academia-de-codigo/parse-content-range-header/blob/master/index.js) peut éventuellement être utilisé.
#### GET {#get}
> **Vous pouvez traduire `GET` par `READ` dans `CRUD`:**
Retourne une ressource (ce peut être une collection ou juste un élément).
Retourner tous les employés :
```bash
$ curl -X GET -H "Accept: application/json" https://xxx/api/v1/employees
```
Retourner l'employé #1 :
```bash
$ curl -X GET -H "Accept: application/json" https://xxx/api/v1/employees/1
```
`GET` est une méthode strictement en lecture seule, qui ne devrait jamais modifier l'état de la ressource ([référence](https://www.belgif.be/specification/rest/api-guide/#get)).
##### GET - Code retourné {#get---returned-code}
| Code | Statut | Raison |
| ----- | --------------- | ----------------------------------------------------------------------------------------------- |
| `200` | `OK` | Le serveur a traité la requête avec succès. La ressource demandée existe et vous est retournée. |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à accéder à la ressource demandée. |
| `404` | `Not found` | La ressource demandée n'existe pas. |
Le code `204 - No content` ne devrait pas être utilisé avec `GET`.
#### POST {#post}
> **Vous pouvez traduire `POST` par `CREATE` dans `CRUD`:**
Avec `POST`, **vous allez créer une nouvelle ressource**. Appeler `POST` cinq fois pour le même « nouvel » employé créera donc cinq nouveaux employés. `POST` n'est pas idempotent : l'appeler plus d'une fois retournera chaque fois une nouvelle ressource (comme le nouvel ID d'employé).
```bash
$" "
`curl -X POST -d '\{"firstname":"Christophe",...}' https://xxx/api/v1/employees`
```
> **Note:**
>
> Lors d'un appel `POST`, vous devez fournir tous les champs requis pour créer la ressource. Donc, pour un nouvel employé : son prénom, son nom, sa date de naissance, son numéro de registre national, sa date d'embauche, le département où il travaillera, et ainsi de suite.
##### POST - Code retourné {#post---returned-code}
L'exemple ci-dessus créera un nouvel employé, le serveur retournera le code de statut HTTP `201` (CREATED) et, probablement, aussi un en-tête location avec un lien, comme `https://xxx/api/v1/employees/999`, c.-à-d. le lien vers l'employé nouvellement créé.
| Code | Statut | Raison |
| ----- | --------------- | ---------------------------------------------------------------------------------------------------------------- |
| `201` | `Created` | La ressource a été créée avec succès. |
| `303` | `See Other` | La ressource existe déjà et un lien vers celle-ci est fourni. `303` indique qu'il s'agit d'un contrôle normal. |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à appeler cette méthode. |
| `409` | `Conflict` | La ressource ne peut pas être créée (p. ex. elle existe déjà). `409` s'utilise quand ce cas n'était pas attendu. |
Le code `200 - OK` ne devrait pas être utilisé avec `POST`.
##### POST - Cas particulier {#post---special-case}
`POST` peut aussi être utilisé pour appeler un contrôleur, comme dans `curl -X POST -d '{"action":"sendBirthdayMail",...}' https://xxx/api/v1/employees/123/actions` ou `curl -X POST https://xxx/api/v1/employees/123/sendBirthdayMail`.
> **Les standards des API RESTful recommandent d'utiliser `POST` pour les actions.:**
>
> Donc, on ne devrait pas faire `GET https://xxx/api/v1/employees/actions` parce que `actions` n'est pas une ressource mais une action.
#### PUT {#put}
> **Vous pouvez traduire `PUT` par `UPDATE` dans `CRUD`, quand vous mettez à jour tous les champs:**
>
> Utiliser `PUT` implique que vous avez l'intention de mettre à jour chaque
> champ de l'enregistrement. Si vous ne prévoyez d'en modifier que
> quelques-uns, jetez un œil au verbe `PATCH`.
Avec `PUT`, vous mettez à jour une ressource existante. Imaginons que notre table employees n'ait que trois champs :
```bash
$" "
`curl -X PUT -d '\{"firstname":"Christophe", "lastname":"AVONTURE", "national_number": 123456789}' https://xxx/api/v1/employees/999`
```
N'utilisez `PUT` que lorsque **vous mettez à jour chaque information** de la ressource (sauf les champs calculés comme par exemple `employee_id`). Si vous ne souhaitez en modifier que quelques-unes (contenu partiel), utilisez plutôt `PATCH`.
`PUT` est idempotent puisque relancer la même mise à jour n'aura pas d'autres
effets sur le serveur. Vous pouvez modifier une fois, cinq fois ou mille fois
le prénom de l'employé #999, le résultat sera toujours le même.
##### PUT - Code retourné {#put---returned-code}
| Code | Statut | Raison |
| ----- | --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `200` | `OK` | L'enregistrement a été mis à jour avec succès et la version mise à jour de la ressource est retournée. |
| `204` | `No Content` | L'enregistrement a été mis à jour avec succès et rien n'est retourné comme réponse HTTP. |
| `400` | `Bad request` | La requête est incorrecte (p. ex. vous essayez de mettre à jour un champ `first_name` alors que son nom correct est `firstname`). |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à appeler cette méthode |
| `404` | `Not found` | La ressource demandée n'existe pas (p. ex. vous avez tenté d'accéder à `/api/v1/employees/1` et celle-ci n'existait pas). |
> **Caution:**
>
> En recevant `PUT -d '{"firstname":"Christophe"}' https://xxx/api/v1/employees/123456`, le développeur de l'API peut décider de lui-même de **CRÉER** la ressource 123456 si elle n'existait pas et si tous les champs requis ont été fournis dans le payload.
>
> Donc, quand la ressource n'existe pas, le développeur peut retourner une erreur `404 - Not found` ou, s'il décide de créer la nouvelle ressource, l'API peut retourner `201 - Created` (comme pour un `POST`) ([référence](https://www.belgif.be/specification/rest/api-guide/#put)).
#### PATCH {#patch}
> **Vous pouvez traduire `PATCH` par `UPDATE` dans `CRUD`, quand vous NE mettez PAS à jour tous les champs:**
>
> Si vous prévoyez de mettre à jour tous les champs, vous devez utiliser `PUT`,
> pas `PATCH`.
`PATCH` s'utilise quand la mise à jour est partielle, comme modifier un seul champ : `curl -X PATCH -d '{"firstname": "John"} https://xxx/api/v1/employees/999'` mettra à jour uniquement le prénom.
Un `PATCH` sur une ressource inexistante retournera une erreur alors que, peut-être, `PUT` créera la ressource. Il est donc plus sûr d'utiliser `PATCH` et pas `PUT` pour mettre à jour un contenu partiel.
Il est en effet impossible de créer un nouvel enregistrement avec `PATCH` puisque la requête ne mentionne que peu d'informations, pas toutes.
##### PATCH - Code retourné {#patch---returned-code}
| Code | Statut | Raison |
| ----- | --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `200` | `OK` | L'enregistrement a été mis à jour avec succès et la version mise à jour de la ressource est retournée. |
| `204` | `No Content` | L'enregistrement a été mis à jour avec succès et rien n'est retourné comme réponse HTTP. |
| `400` | `Bad request` | La requête est incorrecte (p. ex. vous essayez de mettre à jour un champ `first_name` alors que son nom correct est `firstname`). |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à appeler cette méthode |
| `404` | `Not found` | La ressource demandée n'existe pas (p. ex. vous avez tenté d'accéder à `/api/v1/employees/1` et celle-ci n'existait pas). |
Remarque : comme avec `PUT` (voir ci-dessus), le développeur peut décider de créer la ressource. Dans ce cas, `201 - Created` peut être retourné.
#### DELETE {#delete}
> **Vous pouvez traduire `DELETE` par ... `DELETE` dans `CRUD`:**
Pour supprimer l'employé #59 :
```bash
$ curl -X DELETE
https://xxx/api/v1/employees/59
```
##### DELETE - Code retourné {#delete---returned-code}
| Code | Statut | Raison |
| ----- | --------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `200` | `OK` | L'enregistrement a été supprimé avec succès et un message comme *La ressource a été supprimée avec succès* est retourné. |
| `204` | `No Content` | L'enregistrement a été supprimé avec succès et rien n'est retourné comme réponse HTTP. |
| `400` | `Bad request` | La syntaxe de la requête est invalide. |
| `401` | `Unauthorized` | L'endpoint est sécurisé et la requête n'a pas fourni de token d'accès valide. |
| `403` | `Access denied` | Vous n'êtes pas autorisé à appeler cette méthode. |
| `404` | `Not found` | La ressource demandée n'existe pas (p. ex. vous avez tenté d'accéder à `/api/v1/employees/1` et celle-ci n'existait pas). |
| `409` | `Conflict` | La ressource ne peut pas être supprimée (p. ex. on ne peut pas licencier un employé tant qu'il reste des paiements à faire). |
#### OPTIONS {#options}
`OPTIONS` peut être utilisé pour récupérer la liste des actions possibles sur une ressource, comme par exemple `curl -X OPTIONS https://xxx/api/v1/employees/1` : on peut s'attendre à obtenir `GET` (on peut récupérer les métadonnées de l'employé 1), `PATCH/PUT` (on peut le mettre à jour), ... mais on ne récupérera pas `POST` puisque cet employé existe déjà ([référence](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/OPTIONS)).
### Réponses {#responses}
#### Codes de statut {#status-codes}
##### 200 OK {#200-ok}
Un serveur DOIT répondre à une requête réussie de récupération d'une ressource individuelle ou d'une collection de ressources avec une réponse `200 OK`.
Quand `DELETE` a été utilisé, on peut aussi retourner `204 No Content` pour informer l'application appelante que la suppression a réussi.
##### 201 Created {#201-created}
Si la ressource demandée a été créée avec succès et que le serveur modifie la ressource d'une quelconque manière (par exemple en attribuant un ID), le serveur DOIT retourner une réponse `201 Created` avec la nouvelle ressource ajoutée dans le corps.
##### 202 Accepted {#202-accepted}
Si une requête de création/mise à jour ou de suppression d'une ressource a été acceptée pour traitement, mais que le traitement n'est pas terminé au moment où le serveur répond, le serveur DOIT retourner un code de statut `202 Accepted`.
##### 204 No Content {#204-no-content}
Si la ressource demandée a été créée avec succès et que le serveur ne modifie la ressource d'aucune manière (par exemple en attribuant un ID ou un attribut `createdAt`), le serveur DOIT retourner soit un code de statut `201 Created` avec la ressource dans la réponse, soit un code `204 No Content` sans document de réponse.
Si la suppression a été effectuée avec succès, `204 No Content` peut aussi être utilisé.
Pour une mise à jour, c'est pareil : on peut envoyer le code `200 OK` avec la ressource mise à jour dans le corps ou le code `204 No Content` sans corps, juste des en-têtes.
##### 400 Bad Request {#400-bad-request}
Vous devriez retourner une réponse `400 Bad Request` quand l'appel était incorrect, par exemple quand une valeur inconnue est utilisée pour un paramètre (comme `&lang=it` alors que l'italien n'est pas supporté).
##### 401 Unauthorized {#401-unauthorized}
L'API demandée est sécurisée et un appel a été fait sans token d'accès valide.
##### 403 Forbidden {#403-forbidden}
Un serveur DOIT retourner `403 Forbidden` en réponse à une requête non supportée visant, par exemple, à créer une ressource (avec le verbe `POST` et un `ID` généré par le client alors que l'`ID` doit être généré par le serveur, pas par le client).
L'erreur `403 Forbidden` doit aussi être utilisée quand un token valide est utilisé mais que les ressources appelées exigent un scope spécifique (comme `canManageCustomers`) et que le token utilisé ne le possède pas.
##### 404 Not Found {#404-not-found}
Un serveur DOIT répondre `404 Not Found` lors du traitement d'une requête visant à récupérer une ressource unique qui n'existe pas, sauf quand la requête justifie une réponse 200 OK avec null comme donnée principale (comme décrit plus haut).
##### 406 Not Acceptable {#406-not-acceptable}
La requête demande par exemple `application/xml` mais l'API ne le supporte pas. Dans ce cas, le code `406 - Not Acceptable` doit être retourné ([référence](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/406)).
La requête est en effet valide (la structure est correcte) mais le type de retour demandé n'est pas supporté. Par exemple, en remplaçant `application/xml` par `application/json`, la requête peut être traitée.
L'erreur concerne le format de la réponse, pas la requête.
##### 409 Conflict {#409-conflict}
Un serveur DOIT retourner `409 Conflict` lors du traitement d'une requête `POST` visant à créer une ressource avec un `ID` généré par le client qui existe déjà.
Un serveur DOIT retourner `409 Conflict` lors du traitement d'une requête `POST` dans laquelle le type de l'objet ressource ne fait pas partie des types qui constituent la collection représentée par l'endpoint.
##### 415 Unsupported Media Type {#415-unsupported-media-type}
Comme pour `406 Not Acceptable`, le serveur ne peut pas traiter la requête, mais ici c'est parce qu'il ne sait pas comment traiter l'information reçue ([référence](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/415)).
Par exemple, si la requête est un POST avec un payload JSON comme `curl -X POST -d '{"firstname":"Christophe",...}' https://xxx/api/v1/employees`, le serveur peut retourner le code `415 - Unsupported Media Type` s'il attendait un payload XML et pas du JSON.
La réponse contiendrait le type supporté, donc par exemple :
```bash
$ curl -X POST -H "Content-Type: application/x-www-form-urlencoded"
https://xxx/api/v1/employees [...] HTTP/2 415 Unsupported Media Type
Accept-Post: application/json; charset=UTF-8 Content-Length: 0
```
Dans cet exemple, le serveur ne supporte pas `application/x-www-form-urlencoded` pour cette route mais acceptera `application/json`.
#### Types de médias {#media-types}
L'application appelante devrait spécifier le ou les types de médias acceptables via l'en-tête HTTP `Accept`.
Une réponse ou une requête REST avec un payload doit inclure l'en-tête HTTP `Content-Type` pour indiquer le type de média du payload HTTP.
Par exemple, la requête doit définir `-H "Accept: application/json"` dans son en-tête pour indiquer au serveur quel type de média est attendu, et le serveur (la réponse) devrait fournir le `Content-Type` également.
```bash
$ curl -X -H "Accept: application/json" GET https://xxx/api/v1/employees
HTTP/2 200 OK Content-Type: application/json; charset=utf-8
```
Si le retour en JSON n'est pas géré par l'API, le serveur doit retourner le code HTTP `406 - Not Acceptable`.
#### La réponse retournée {#the-returned-answer}
Toutes les API RESTful doivent supporter JSON, et c'est la représentation par défaut à utiliser (c.-à-d. quand aucun en-tête `Content-Type` n'est envoyé avec la requête).
Les règles suivantes DEVRAIENT être respectées pour déterminer le nom d'une propriété JSON ([référence](https://www.belgif.be/specification/rest/api-guide/#json-2)) :
* utiliser la notation lowerCamelCase,
* également pour les abréviations : toutes les lettres après la première doivent être en minuscules,
* utiliser l'anglais américain,
* ne pas utiliser d'underscores (\_), de tirets (-) ou de points (.) dans un nom de propriété, ni de chiffre comme première lettre,
* le nom devrait refléter la signification métier de sa valeur par rapport à l'objet dans lequel elle est définie, plutôt que la façon dont elle est définie, et
* omettre les parties du nom qui sont déjà évidentes d'après le contexte.
Les propriétés issues d'autres standards, comme OpenID Connect et OAuth2, sont autorisées à dévier de cette règle.
##### Gestion des valeurs null {#handling-null-values}
Quand une propriété vaut null, cette propriété ne devrait pas être envoyée. Par exemple :
```json
{
"name": "CompanyName",
"country": "Belgium",
"employerId": null
}
```
Dans ce cas, la clé `employerId` peut être omise.
##### Ne retournez pas un tableau comme réponse {#dont-return-an-array-for-the-answer}
Voir aussi le chapitre suivant.
Pour rendre l'API flexible, assurez-vous que la réponse est un objet JSON `{ ... }` et pas un tableau `[ ... ]`, car ce dernier ne permettra pas d'ajouter des propriétés supplémentaires dans le futur ([référence](https://www.belgif.be/specification/rest/api-guide/#rule-evo-object)).
##### Niveau supérieur {#top-level}
>
Un document DOIT contenir au moins un des membres de niveau supérieur suivants :
* `data` : les *données principales* du document. Ce nœud doit être renommé pour mieux décrire l'objet, par exemple `employees` ou `countries`, ...
* `errors` : un tableau d'objets d'erreur
* `meta` : un méta-objet qui contient des méta-informations non standard.
> **Ne retournez jamais à la fois `data` et `errors`:**
>
> Si la query a généré une erreur, on ne s'attend pas à recevoir des données. Et inversement.
Les *données principales* du document sont une représentation de la ressource ou de la collection de ressources ciblée par la requête.
`data` peut être un tableau ou non :
```json
{
"data": [
{
"key1": "value1",
"key2": "value2"
},
{
"key1": "value1",
"key2": "value2"
}
]
}
```
ou, s'il n'y a qu'un seul enregistrement, directement :
```json
{
"data": {
"key1": "value1",
"key2": "value2"
// ...
}
}
```
> **La plupart du temps, c'est le développeur qui choisit d'utiliser un tableau:**
>
> En fait, la plupart du temps, un tableau est retourné, car cela permet de
> modifier l'API plus tard pour retourner d'autres résultats sans générer de BC
> (break changes) dans l'API.
##### Data {#data}
>
Comme décrit dans [les éléments de niveau supérieur](https://jsonapi.org/format/#document-top-level), l'information retournée devrait être placée dans un nœud de niveau supérieur appelé `data` :
Le mieux est de toujours utiliser la notation tableau afin de pouvoir retourner une ou plusieurs rows.
```json
'data': [
[...]
]
```
##### Retourner une erreur {#returning-an-error}
>
>
>
Comme décrit dans [les éléments de niveau supérieur](https://jsonapi.org/format/#document-top-level), les erreurs devraient être placées dans un nœud de niveau supérieur, au pluriel :
```json
"errors": [
[...]
]
```
Préférez le code de statut HTTP `400 Bad Request` quand l'appel était incorrect, par exemple quand une valeur inconnue est utilisée pour un paramètre (comme `&lang=it` alors que l'italien n'est pas supporté). Utilisez le code `500 Internal Server Error` quand le serveur a retourné une erreur.
`errors` doit être un tableau afin de pouvoir retourner plus d'une erreur et devrait contenir au moins une de ces clés (*liste non exhaustive*) :
* `status` : le code de statut HTTP applicable à ce problème, exprimé sous forme de chaîne. Cette clé DEVRAIT être fournie.
* `code` : un code d'erreur spécifique à l'application, exprimé sous forme de chaîne.
* `title` : un résumé court et lisible par un humain du problème, qui NE DEVRAIT PAS changer d'une occurrence à l'autre du problème, sauf pour des raisons de localisation.
* `parameter` : une chaîne indiquant quel paramètre de query de l'URI a provoqué l'erreur.
```json
"errors": [
{
"status": "400",
"code": "ERR_INVALID_VIEW",
"title": "Language code 'it' is not supported",
"parameter": "lang"
},
[...]
]
```
#### La réponse d'une page paginée {#the-response-of-a-paginated-page}
Avec la pagination, la réponse JSON de `GET https://xxx/api/v1/employees?page=2&pageSize=2` peut ressembler à ceci ([référence](https://www.belgif.be/specification/rest/api-guide/#rule-col-paging)) :
```json
{
"self": "/api/v1/employees?page=2&pageSize=2",
"data": [
{
"href": "/api/v1/employees/3",
"id": 3,
"firstname": "John",
"lastname": "Doe"
},
{
"href": "/api/v1/employees/4",
"id": 4,
"firstname": "Jane",
"lastname": "Doe"
}
],
"pageSize": 2,
"page": 2,
"total": 7,
"first": "/api/v1/employees?pageSize=2",
"last": "/api/v1/employees?page=4&pageSize=2",
"prev": "/api/v1/employees?page=1&pageSize=2",
"next": "/api/v1/employees?page=3&pageSize=2"
}
```
| Clé | Description |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `next` | OBLIGATOIRE (sauf pour la dernière page) hyperlien vers la page suivante |
| `prev` | OPTIONNEL hyperlien vers la page précédente |
| `pageSize` | RECOMMANDÉ Nombre maximum d'éléments par page. Pour la dernière page, sa valeur devrait être indépendante du nombre d'éléments réellement retournés. |
| `page` | OBLIGATOIRE (basé sur l'offset) ; N/A (basé sur un curseur) Index de la page courante, devrait commencer à 1 (la page par défaut et première page est 1) |
| `first` | OPTIONNEL Hyperlien vers la première page |
| `last` | OPTIONNEL Hyperlien vers la dernière page |
| `total` | OPTIONNEL Nombre total d'éléments sur toutes les pages. Si des paramètres de query sont utilisés pour filtrer le jeu de résultats, il s'agit du total du jeu de résultats, pas de la collection entière. |
### Endpoints attendus {#expected-endpoints}
Chaque API REST devrait exposer un endpoint `GET /api/v1/health` qui retourne la disponibilité des fonctionnalités de l'API.
| Statut | Code de statut | Description |
| ---------- | -------------- | ------------------------------------------------------------------------------------------------- |
| `UP` | `200` | L'API fonctionne comme prévu. |
| `DEGRADED` | `200` | L'API est partiellement indisponible, mais le service peut continuer avec des fonctions réduites. |
| `DOWN` | `503` | L'API subit des pannes inattendues. |
## Quelques lectures {#some-lectures}
* [7 Laravel RESTful APIs best practices for 2023](https://benjamincrozat.com/laravel-restful-api-best-practices)
* [Belgif - REST Guidelines](https://www.belgif.be/specification/rest/api-guide)
---
# Bruno - Un outil à la Postman - GUI et CLI
> Découvrez Bruno, un outil de test d'API puissant à la Postman, avec support GUI et CLI.
---

**TL;DR**
Cet article présente Bruno, une alternative libre et gratuite à Postman pour tester vos API. Il vous guide dans la mise en place d'une API Python FastAPI d'exemple, puis dans son test via l'interface graphique et le CLI de Bruno. Vous apprendrez à créer des requêtes, gérer des environnements et lancer des tests depuis la ligne de commande avec Docker, y compris comment gérer les problèmes réseau classiques. L'article aborde aussi l'ajout d'assertions pour valider vos tests.
[Bruno](https://www.usebruno.com/) est un outil comme Postman, utilisable gratuitement. Tout est stocké sur votre machine : vous pouvez donc conserver les fichiers dans votre codebase et les envoyer dans votre outil de versioning, par exemple.
*Comme ces fichiers vivent dans votre repository, la partie CLI de Bruno se transforme naturellement en job de CI — voir [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker). Et pour vérifier le contrat de l'API plutôt que ses réponses, il y a [Validate your OpenAPI schema against the Belgif REST standards](/blog/belgif-api-linter).*
## Lancer Bruno {#run-bruno}
Une fois installé, démarrez simplement `bruno` depuis la ligne de commande pour lancer l'interface :

Commençons par le commencement : créons une collection.

Et, comme on est malins, créons aussi un environnement (pour définir l'URL racine de notre site une seule fois) :

Nous voilà prêts, créons une nouvelle requête :

Récupérer une blague au hasard :

Une fois la requête créée, appuyez sur `CTRL`+`ENTER` ou cliquez sur la flèche de droite :

## Pourquoi ça fonctionne {#why-it-works}
* Tout — collection, environnements, requêtes — est stocké sous forme de fichiers texte `.bru` dans votre repo : une requête se diffe et se relit comme du code.
* La même collection s'exécute de deux façons : en mode exploration dans la GUI, ou en vérifications scriptées depuis le CLI — pas d'outil séparé à garder synchronisé.
* Une collection correspond à un dossier unique et propre (voir la section *Ouvrir le projet avec VSCode* ci-dessous) ; rien ne vit dans une base de données ou un compte cloud que vous ne contrôlez pas.
## Installation {#installation}
Pour installer la GUI de Bruno sur ma distribution Ubuntu, je lance ces commandes :
```bash
$ sudo mkdir -p /root/.gnupg
$ sudo chmod 700 /root/.gnupg
$ sudo mkdir -p /etc/apt/keyrings
$ sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
$ echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
$ sudo apt update
$ sudo apt install bruno
```
Consultez la documentation officielle [Download & Install](https://docs.usebruno.com/get-started/bruno-basics/download) pour plus d'infos.
**Optionnel - Démarrer une API de test pour essayer Bruno (cliquez pour les détails)**
Bruno a besoin de quelque chose à interroger. Si vous n'avez pas d'API sous la main, voici une API de test FastAPI en une minute, empruntée à [« Python - Fast API - Créez votre API JSON en Python en une minute »](/blog/python-fastapi) :
1. Lancez `mkdir /tmp/fastapi && cd $_` pour créer un dossier temporaire et vous y placer
2. Créez un `Dockerfile` avec le contenu ci-dessous
3. Créez un `main.py` avec le contenu ci-dessous
4. Lancez la commande `docker build -t python-fastapi . && docker run --detach -v .:/app -p 82:82 python-fastapi` pour démarrer le serveur
5. Ouvrez un navigateur sur `http://127.0.0.1:82/jokes` pour voir une première blague (F5 pour en obtenir une nouvelle ; au hasard)
**Dockerfile**
```docker
# We'll use the latest version of Python and the smaller image in size (i.e. `slim`)
FROM python:slim
# We'll define the default folder in the image to /app
WORKDIR /app
# The only dependency we need is fastapi
RUN pip install --no-cache-dir 'fastapi[standard]'
# We need to copy our Python script in the image
COPY main.py main.py
# Run Uvicorn with hot reload so any changes to our main.py script will force
# the server to invalidate the cache and refresh the page.
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "82"]
```
**main.py**
```python
from random import choice
from fastapi import FastAPI
app = FastAPI()
jokes = [
"Why do programmers always mix up Halloween and Christmas? Because Oct 31 == Dec 25",
"Why don't programmers like nature? Because it's full of bugs!",
"How many programmers does it take to change a light bulb? None. That's a hardware problem.",
"What do you call 8 hobbits? A hobbyte",
"What is this [“hip”, ”hip”]? hip hip array!"
]
@app.get("/jokes")
async def get_jokes():
"""
Returns a random joke from the list.
"""
return {"joke": choice(jokes)}
@app.get("/jokes/{joke_id}")
def read_item(joke_id: int):
"""
Returns a specific joke (between 0 and 4).
"""
try:
return {"joke": jokes[joke_id]}
except IndexError:
return {"error": f"Joke with ID {joke_id} not found."}, 404
```
## Ouvrir le projet avec VSCode {#opening-the-project-with-vscode}
En ouvrant le projet dans VSCode, on voit un nouveau dossier appelé `Jokes` (notre collection) avec très peu de fichiers : `environments/dev.bru` où se trouvent les variables de notre environnement, un fichier générique `bruno.json`, puis notre requête dans `Get a random joke.bru`.
Structure très propre, non ?

## Exécuter des requêtes depuis la ligne de commande {#running-requests-from-the-command-line}
Bruno propose une [image Docker](https://hub.docker.com/r/alpine/bruno) : elle va nous aider à automatiser l'exécution de nos requêtes depuis la ligne de commande.
> **Caution:**
>
> À la mi-juillet 2025, je n'ai pas réussi à faire fonctionner cette image comme prévu. Je tombais sur des erreurs *Cannot read properties of undefined (reading 'headers')* alors que, à mon avis, tout était correctement configuré.
>
> J'ai donc cherché une autre image et j'ai trouvé celle-ci : [davidkarlsen/bruno-image](https://github.com/davidkarlsen/bruno-image). Pas de chance non plus, même la dernière version de l'époque (2.7.0) posait problème.
>
> En regardant le [Dockerfile](https://github.com/davidkarlsen/bruno-image/blob/main/Dockerfile), j'ai vu que le fichier était vraiment simple et qu'une version plus récente de [Bruno était sortie](https://github.com/usebruno/bruno/tags) : la 2.8.
>
> Bref, je vais créer ma propre image Docker et voir si ça va mieux.
### Créer notre propre image Bruno CLI {#create-our-own-bruno-cli-image}
Créons un fichier `bruno.Dockerfile` avec le contenu suivant :
**bruno.Dockerfile**
```docker
FROM node:lts-bookworm-slim
// highlight-next-line
RUN npm install -g @usebruno/cli@2.8.0
ENTRYPOINT ["bru"]
CMD ["run"]
```
On crée notre image comme ceci : `docker build --file bruno.Dockerfile -t bruno-image .` (on peut la vérifier en lançant `docker run -it --rm bruno-image --version` ; on devrait voir `2.8.0`).
Comme on vient de le voir, notre collection est stockée dans le dossier `Jokes` et notre environnement dans `environments/dev.bru`. Mais un container ne peut pas joindre `127.0.0.1` sur votre host : créez donc un second fichier d'environnement, `environments/dev-docker.bru`, qui pointe vers `host.docker.internal` :
```none
vars {
host: http://host.docker.internal:82
}
```
Lancez maintenant la collection avec cet environnement :
```bash
$ docker run -it --rm -v "./Jokes":/apps -w /apps
--add-host host.docker.internal:host-gateway
bruno-image run --env=dev-docker
```

### Sous le capot (à sauter si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}
Pourquoi ne pas simplement réutiliser `environments/dev.bru`, celui que la GUI utilise déjà ? Parce que l'exécuter tel quel depuis le CLI échoue :

Regardez le message d'erreur `connect ECONNREFUSED 127.0.0.1:82` : le container Bruno CLI essaie de joindre le serveur web sur `127.0.0.1`, mais cette adresse, c'est le container lui-même, pas votre host. `environments/dev.bru` ressemble à ceci :
```none
vars {
root: http://127.0.0.1:82
}
```
Ça fonctionne depuis la GUI, parce que la GUI tourne directement sur votre host — là, `127.0.0.1` *est* votre machine. Un `curl -v http://127.0.0.1:82/jokes` depuis la ligne de commande de votre host fonctionne aussi :
```bash
$ curl -v http://127.0.0.1:82/jokes
{`
* Uses proxy env variable no_proxy == 'localhost,127.0.0.1,*.local'
* Trying 127.0.0.1:82...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 82 (#0)
> GET /jokes HTTP/1.1
> Host: 127.0.0.1:82
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< date: Wed, 23 Jul 2025 08:36:40 GMT
< server: uvicorn
< content-length: 72
< content-type: application/json
<
* Connection #0 to host 127.0.0.1 left intact
\{"joke":"Why don't programmers like nature? Because it's full of bugs!"}
`}
```
Un container a son propre namespace réseau : `127.0.0.1` à l'intérieur n'atteint jamais l'host. C'est exactement ce que corrigent `environments/dev-docker.bru` et `host.docker.internal` ci-dessus. Effet de bord : ce nouveau fichier d'environnement ne fonctionnera pas depuis la GUI de Bruno — on peut sélectionner `dev` et tout marche, mais pas `dev-docker` :

## Ajouter quelques assertions {#adding-some-assertions}
L'intérêt de l'outil CLI, bien sûr, c'est de pouvoir lancer des assertions depuis la ligne de commande et s'assurer que l'API fonctionne toujours.
Mettons à jour le fichier `Get a random joke.bru` comme ceci :
**Get a random joke.bru**
```bru
meta {
name: Get a random joke
type: http
seq: 1
}
get {
url: {{host}}/jokes
body: none
auth: none
}
assert {
res.status: eq 200
res.headers["content-type"]: isDefined
res.headers["content-type"]: isString
res.headers["content-type"]: contains "application/json"
res.body.joke.length: gt 0
res.body.joke: isString
}
settings {
encodeUrl: true
}
```

## Conclusion {#conclusion}
Bruno vous offre un workflow à la Postman — collections, environnements, assertions — sans quitter votre repository : chaque requête est un fichier `.bru` que vous pouvez differ, relire et versionner comme le reste de votre code. La GUI sert à explorer et à construire une collection ; le CLI, emballé dans votre propre image Docker, transforme cette même collection en vérification reproductible.
Cette reproductibilité, c'est exactement ce qu'attend un pipeline de CI. Voir [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker) pour lancer cette image CLI comme job, et [Validate your OpenAPI schema against the Belgif REST standards](/blog/belgif-api-linter) pour vérifier le contrat de l'API, pas seulement ses réponses.
---