Aller au contenu

Concevoir une API

Jusqu’ici, nous avons construis des applications web complètent, mais fermés sur eux même. Ici, nous voyons comment ouvrir son application à d’autres programmes, de façon organisée et prévisible. Ceci permettra de centraliser la logique métier et l’accès aux données tout en laissant flexible l’exploitation et la présentation de ceux-ci.

On change également d’outil pour construire les APIs: l’API sera écrite en JavaScript, avec Node.js et Express.

Revenons à notre exemple du catalogue de L’Érablière dorée. Nous avons construit un site web présentant ses produits, mais ce n’est pas nécessairement le seule client qui pourrait présenter ou exploiter le catalogue.

  • Une application mobile pourrait également afficher les produits
  • un partenaire pourrait vouloir les revendre,
  • un script d’un administrateur système pourrait voiloir mettre les stocks à jour.

Sans API, chacun devrait soit accéder directement à la base de données, soit extraire les données du HTML des pages, ce qui n’est pas commode. De plus, le moindre changement au niveau de la base de données ou dans le rendu pourrait briser les clients.

Une API vient répondre à quelques besoins récurrents

Section intitulée « Une API vient répondre à quelques besoins récurrents »
  • plusieurs clients pour les mêmes données et les mêmes règles ;
  • des rythmes séparés : le client et le serveur évoluent chacun de leur côté, parfois écrits par des équipes différentes, dans des langages différents ;
  • une seule mise en œuvre des règles métier, au lieu d’une copie par client ;
  • exposer une capacité sans exposer l’implémentation : le client n’a besoin de connaître ni la base, ni le code, ni l’organisation des fichiers.

Le mot API désigne des choses de tailles très différentes.

Sorte d’API Exemple Comment on l’utilise
De bibliothèque ou de langage les fonctions fournis par PHP comme json_encode(), l’API du DOM du navigateur par un appel de fonction, dans le même programme
De système d’exploitation ouvrir un fichier, lancer un processus par un appel système, que le langage enveloppe
Web un service de météo, de paiement, de cartographie par des requêtes HTTP, à travers le réseau

Parmi les API web, plusieurs styles coexistent:

  • Une API REST, présentée plus bas, organise tout autour de ressources désignées par des adresses.
  • GraphQL laisse le client décrire, dans une requête unique, la forme exacte des données qu’il veut recevoir.
  • Le style RPC consiste à appeler des procédures nommées à distance, comme ajouterProduit.

Aucun n’est universellement supérieur ; REST reste le plus répandu pour les API publiques simples, et c’est lui que la suite de la page développe.

Les principes REST décrivent un style architectural pour concevoir des systèmes distribués, notamment des API Web.

REST définit plusieurs contraintes architecturales. Pour concevoir notre première API, nous en retiendrons principalement trois.

  1. Communication client–serveur : le client gère principalement la présentation et les interactions; le serveur gère les ressources, les données et la logique associée. Cette séparation permet aux deux d’évoluer plus indépendamment.
  2. Sans état (stateless) :chaque requête contient toute l’information nécessaire pour être comprise et traitée. Le serveur ne dépend pas d’un état de conversation conservé entre deux requêtes d’un même client.
  3. Interface uniforme : les ressources sont manipulées suivant des conventions communes et prévisibles. C’est probablement la contrainte la plus importante de REST. Elle implique notamment :
    • l’identification des ressources, généralement par des URI;
    • la manipulation des ressources à travers leurs représentations (JSON, XML, etc.);
    • des messages suffisamment explicites pour être compris;
    • idéalement, des liens permettant au client de découvrir les actions ou ressources accessibles (HATEOAS).

Si nous appliquons REST au catalogue de l’Érablière, nous obtenons les cinq points d’accès suivants.

Méthode Adresse Action Réponse en cas de succès
GET /api/produits lister les produits 200, la liste
GET /api/produits/7 lire un produit 200, le produit
POST /api/produits créer un produit 201, le produit, et son adresse dans Location
PUT /api/produits/7 remplacer un produit 200, le produit modifié
DELETE /api/produits/7 supprimer un produit 204, sans corps

Et quatre réponses d’erreur, chacune pour une raison distincte.

Statut Signification Exemple
400 la requête est illisible le corps n’est pas du JSON valide
404 la ressource n’existe pas /api/produits/99, ou /api/clients
405 la méthode ne convient pas à cette ressource DELETE /api/produits, sur la collection entière
422 la requête est lisible, mais refusée un prix négatif
VérificationPourquoi préférer DELETE /api/produits/7 à GET /api/supprimerProduit?id=7 ?

Pour deux raisons. La première est la clarté du contrat : l’adresse désigne toujours la même ressource, et la méthode seule indique l’action, si bien qu’un client qui connaît /api/produits/7 sait déjà comment la lire, la remplacer et la supprimer. La seconde est la sécurité, déjà rencontrée avec les formulaires : une requête GET peut être déclenchée sans intention, par un préchargement ou un robot, et ne devrait rien modifier.

Node.js s’installe depuis nodejs.org, en version LTS (Long Term Support, la version maintenue le plus longtemps). XAMPP n’intervient pas ici : Node.js est lui-même le serveur.

Fenêtre de terminal
mkdir erabliere-api
cd erabliere-api
npm init -y # crée package.json
npm install express # télécharge Express

Deux ajouts au package.json créé par npm init :

{
"name": "erabliere-api",
"version": "1.0.0",
"type": "module",
"main": "src/serveur.js",
"scripts": {
"dev": "node --watch src/serveur.js",
"start": "node src/serveur.js"
},
"dependencies": {
"express": "^5.3.0"
}
}
  • "type": "module" permet d’écrire import et export, la syntaxe des modules qu’on retrouvera avec Vue.js.
  • Le script dev lance le serveur avec --watch : Node.js le redémarre à chaque modification d’un fichier. On le lance par npm run dev.
// src/serveur.js : une première version, remplacée plus bas
import express from 'express';
const app = express();
app.get('/api/bonjour', (req, res) => {
res.json({ message: 'Bonjour du catalogue' });
});
app.listen(3000, () => {
console.log('Serveur prêt : http://localhost:3000/api/bonjour');
});
  • express() crée l’application, qui recevra toutes les requêtes.
  • app.get(adresse, fonction) déclare une route : pour une requête GET à cette adresse, Express appelle la fonction avec deux objets, la requête req et la réponse res.
  • res.json() convertit l’objet en JSON, pose l’en-tête Content-Type: application/json et envoie la réponse.
  • app.listen(3000) ouvre le port 3000 et attend les requêtes.
VérificationLe serveur tourne. Vous modifiez le message de la route, sans utiliser --watch, puis vous rechargez la page. Pourquoi voyez-vous encore l'ancien message ?

Parce que le programme a lu le fichier au démarrage, et qu’il continue de s’exécuter avec cette version. En PHP, chaque requête relit le fichier, d’où l’effet immédiat d’une modification. Avec Node.js, il faut redémarrer le serveur ; l’option --watch le fait automatiquement à chaque enregistrement.

L’API offre les cinq points d’accès définis plus haut. Elle se construit en trois étapes, chacune motivée par les limites de la précédente :

  1. les cinq points d’accès, dans un seul fichier ;
  2. la vérification de ce que le client envoie ;
  3. l’organisation du code avec un routeur.

Pour se concentrer sur Express, l’API garde ses produits dans un tableau en mémoire, plutôt que dans la base MySQL utilisée par les pages.

Tout tient dans un seul fichier, src/serveur.js.

erabliere-api/
├── package.json
├── node_modules/ créé par npm install
└── src/
└── serveur.js les données, les cinq routes et le démarrage

Voici le trajet d’une création, de la requête du client jusqu’à la réponse.

  1. Client vers serveur.js : POST /api/produits (corps JSON)
  2. serveur.js : app.post('/api/produits', …)
  3. serveur.js vers Tableau en mémoire : produits.push(produit)
  4. serveur.js vers Client : 201 · Location: /api/produits/7
Créer un produit, étape 1

Express offre, pour la requête et la réponse, tout ce dont l’API a besoin.

Express Rôle
app.get(adresse, fonction), app.post(…), app.put(…), app.delete(…) déclarer une route pour une méthode et une adresse
req.params.id la partie de l’adresse désignée par :id, toujours sous forme de texte
req.body le corps JSON de la requête, déjà décodé
res.status(404) choisir le code de statut ; se combine avec la suite
res.json(objet) envoyer l’objet en JSON
res.location(adresse) poser l’en-tête Location
res.sendStatus(204) envoyer un statut seul, sans corps

Par défaut, Express ne lit pas le corps d’une requête. La ligne app.use(express.json()), placée avant les routes, lui demande de lire le JSON reçu et de le ranger dans req.body.

src/serveur.js

Étape 1 : les données, les cinq points d'accès et le démarrage, dans un seul fichier.

La spécification
  • Garde les six produits de départ en mémoire, et attribue un identifiant à chaque nouveau produit.
  • Collection : liste en lecture ; création, réussie avec 201 et l’adresse du nouveau produit.
  • Produit : lecture, remplacement, suppression ; 404 s’il n’existe pas.
  • Suppression réussie : 204, sans corps.
  • Écoute sur le port 3000.

Les requêtes prévues fonctionnent. Mais une API ne choisit pas ses clients : n’importe quel programme peut lui envoyer n’importe quoi. Voici ce que répond l’étape 1 à quatre requêtes moins sages.

Requête Réponse de l’étape 1
POST avec {"nom": "Pastilles"} 201 : un produit sans catégorie, sans prix ni stock, est créé
POST avec un prix de -3 et la catégorie fromage 201 : le produit est créé tel quel
POST sans corps 500, et une page d’erreur HTML : req.body vaut undefined, et la route plante
POST avec {"nom": 400, mais sous forme de page HTML, illisible pour un programme
L’étape 1 croit tout ce que le client lui envoie.
VérificationLes pages HTML du catalogue avaient des champs obligatoires et un champ de prix numérique. Pourquoi ces protections ne suffiraient-elles pas pour l'API ?

Parce qu’elles vivent dans le formulaire, donc chez le client. Une API ne reçoit pas ses requêtes d’un formulaire qu’elle a elle-même produit : elle les reçoit de curl, d’un script, d’une application ou d’une page écrite par d’autres. La seule vérification sur laquelle le serveur peut compter est la sienne. C’était déjà vrai pour les pages, où un formulaire modifié dans le navigateur contourne les attributs required.

Les quatre requêtes de l’épreuve révèlent deux sortes de problèmes, qui appellent deux statuts différents :

  • 400 : le corps est absent, illisible, ou n’est pas un objet JSON. L’API ne peut même pas lire la demande.
  • 422 : le corps est un objet lisible, mais ses valeurs sont refusées : nom vide, catégorie inconnue, prix négatif. L’API répond avec les anomalies par champ, sous la clé erreurs.

Deux fonctions s’ajoutent en tête du fichier, après les données. Les règles sont celles du guide sur la base de données.

const categories = ['sirop', 'tartinade', 'confiserie', 'coffret'];
// Vrai si le corps de la requête est un objet JSON (et non un tableau, ou rien)
function estObjet(corps) {
return typeof corps === 'object' && corps !== null && !Array.isArray(corps);
}
// Vérifie un produit reçu ; rend les anomalies par champ, vide si tout va bien
function valider(donnees) {
const erreurs = {};
if (typeof donnees.nom !== 'string' || donnees.nom.trim() === '') {
erreurs.nom = 'Le nom est obligatoire et doit être du texte.';
} else if (donnees.nom.trim().length > 100) {
erreurs.nom = 'Le nom ne devrait pas dépasser 100 caractères.';
}
if (!categories.includes(donnees.categorie)) {
erreurs.categorie = 'Choisissez une catégorie de la liste.';
}
if (typeof donnees.prix !== 'number' || !(donnees.prix > 0)) {
erreurs.prix = 'Le prix doit être un nombre positif.';
}
if (!Number.isInteger(donnees.stock) || donnees.stock < 0) {
erreurs.stock = 'Le stock doit être un entier positif ou nul.';
}
return erreurs;
}

La route de création s’en sert avant d’ajouter quoi que ce soit :

app.post('/api/produits', (req, res) => {
if (!estObjet(req.body)) {
return res.status(400).json({ erreur: 'Le corps de la requête doit être un objet JSON.' });
}
const erreurs = valider(req.body);
if (Object.keys(erreurs).length > 0) {
return res.status(422).json({ erreurs });
}
const { nom, categorie, prix, stock } = req.body;
const produit = { id: prochainId++, nom: nom.trim(), categorie, prix, stock };
produits.push(produit);
res.status(201).location(`/api/produits/${produit.id}`).json(produit);
});

La route PUT reçoit les mêmes vérifications, après le 404 : on vérifie d’abord que le produit existe, puis ce que le client propose de mettre à sa place.

Le fichier complet après l’étape 2
import express from 'express';
const app = express();
// Permet de lire un corps JSON dans req.body
app.use(express.json());
// Les produits, gardés en mémoire tant que le serveur tourne
const produits = [
{ id: 1, nom: "Sirop d'érable doré 540 ml", categorie: 'sirop', prix: 14.5, stock: 12 },
{ id: 2, nom: "Sirop d'érable ambré 540 ml", categorie: 'sirop', prix: 15.25, stock: 7 },
{ id: 3, nom: "Beurre d'érable 250 g", categorie: 'tartinade', prix: 9.95, stock: 0 },
{ id: 4, nom: "Gelée d'érable 190 ml", categorie: 'tartinade', prix: 11, stock: 9 },
{ id: 5, nom: 'Tire sur la neige', categorie: 'confiserie', prix: 6.25, stock: 30 },
{ id: 6, nom: 'Coffret découverte', categorie: 'coffret', prix: 39, stock: 5 },
];
let prochainId = 7;
const categories = ['sirop', 'tartinade', 'confiserie', 'coffret'];
// Vrai si le corps de la requête est un objet JSON (et non un tableau, ou rien)
function estObjet(corps) {
return typeof corps === 'object' && corps !== null && !Array.isArray(corps);
}
// Vérifie un produit reçu ; rend les anomalies par champ, vide si tout va bien
function valider(donnees) {
const erreurs = {};
const nom = String(donnees.nom ?? '').trim();
if (nom === '') {
erreurs.nom = 'Le nom est obligatoire.';
} else if (nom.length > 100) {
erreurs.nom = 'Le nom ne devrait pas dépasser 100 caractères.';
}
if (!categories.includes(donnees.categorie)) {
erreurs.categorie = 'Choisissez une catégorie de la liste.';
}
if (typeof donnees.prix !== 'number' || !(donnees.prix > 0)) {
erreurs.prix = 'Le prix doit être un nombre positif.';
}
if (!Number.isInteger(donnees.stock) || donnees.stock < 0) {
erreurs.stock = 'Le stock doit être un entier positif ou nul.';
}
return erreurs;
}
// GET /api/produits : la liste
app.get('/api/produits', (req, res) => {
res.json(produits);
});
// GET /api/produits/:id : un produit
app.get('/api/produits/:id', (req, res) => {
const produit = produits.find((p) => p.id === Number(req.params.id));
if (!produit) {
return res.status(404).json({ erreur: 'Produit introuvable.' });
}
res.json(produit);
});
// POST /api/produits : créer un produit
app.post('/api/produits', (req, res) => {
if (!estObjet(req.body)) {
return res.status(400).json({ erreur: 'Le corps de la requête doit être un objet JSON.' });
}
const erreurs = valider(req.body);
if (Object.keys(erreurs).length > 0) {
return res.status(422).json({ erreurs });
}
const { nom, categorie, prix, stock } = req.body;
const produit = { id: prochainId++, nom: nom.trim(), categorie, prix, stock };
produits.push(produit);
res.status(201).location(`/api/produits/${produit.id}`).json(produit);
});
// PUT /api/produits/:id : remplacer un produit
app.put('/api/produits/:id', (req, res) => {
const produit = produits.find((p) => p.id === Number(req.params.id));
if (!produit) {
return res.status(404).json({ erreur: 'Produit introuvable.' });
}
if (!estObjet(req.body)) {
return res.status(400).json({ erreur: 'Le corps de la requête doit être un objet JSON.' });
}
const erreurs = valider(req.body);
if (Object.keys(erreurs).length > 0) {
return res.status(422).json({ erreurs });
}
const { nom, categorie, prix, stock } = req.body;
Object.assign(produit, { nom: nom.trim(), categorie, prix, stock });
res.json(produit);
});
// DELETE /api/produits/:id : supprimer un produit
app.delete('/api/produits/:id', (req, res) => {
const position = produits.findIndex((p) => p.id === Number(req.params.id));
if (position === -1) {
return res.status(404).json({ erreur: 'Produit introuvable.' });
}
produits.splice(position, 1);
res.sendStatus(204);
});
app.listen(3000, () => {
console.log('API du catalogue : http://localhost:3000/api/produits');
});

L’API fonctionne et est encore maintenable. Cependant, serveur.js compte déjà 125 lignes, et plusieurs signes montrent qu’il ne tiendra pas longtemps ainsi.

  • Il fait quatre choses à la fois : il garde les données, applique les règles, déclare les routes et démarre le serveur. Chacune est une raison distincte de le modifier.
  • Les adresses se répètent : '/api/produits' est écrit deux fois, '/api/produits/:id' trois fois. Renommer la ressource, ou la placer sous /api/v1, demande de modifier chaque route.
  • La prochaine ressource s’y mêlera : les commandes, proposées en exercice plus bas, ajouteraient leurs routes et leurs règles au même fichier.

Le changement tient en peu de lignes :

// Avant, dans serveur.js
app.get('/api/produits/:id', (req, res) => { /* … */ });
// Après, dans routes/produits.js
const router = Router();
router.get('/:id', (req, res) => { /* … */ });
export default router;
// Et dans app.js, une seule fois pour toutes les routes des produits
app.use('/api/produits', router);

Le préfixe n’est plus écrit qu’une fois.

Le fichier unique server.js se répartit alors en quatre modules, chacun avec une seule raison de changer.

erabliere-api/
├── package.json
└── src/
├── serveur.js démarre l'écoute
├── app.js crée l'application, branche le routeur, traite les cas restants
├── routes/
│ └── produits.js les points d'accès sur les produits, et leurs vérifications
└── donnees/
└── produits.js les produits, gardés en mémoire
  1. Client vers app.js : POST /api/produits (corps JSON)
  2. app.js : express.json()
  3. app.js vers routes/produits.js : /api/produits → routeur
  4. routes/produits.js : POST / → valider(req.body)
  5. routes/produits.js vers donnees/produits.js : produits.push(produit)
  6. routes/produits.js vers Client : 201 · Location: /api/produits/7
Créer un produit, étape 3

Le tableau passe dans son propre module. Il exporte les produits, la liste des catégories permises, et une fonction qui attribue les identifiants ; le compteur prochainId, lui, reste privé au module.

// Les produits du catalogue, gardés en mémoire.
// Ils sont partagés par toutes les requêtes, et reviennent à cet état
// à chaque redémarrage du serveur.
export const categories = ['sirop', 'tartinade', 'confiserie', 'coffret'];
export const produits = [
{ id: 1, nom: "Sirop d'érable doré 540 ml", categorie: 'sirop', prix: 14.5, stock: 12 },
{ id: 2, nom: "Sirop d'érable ambré 540 ml", categorie: 'sirop', prix: 15.25, stock: 7 },
{ id: 3, nom: "Beurre d'érable 250 g", categorie: 'tartinade', prix: 9.95, stock: 0 },
{ id: 4, nom: "Gelée d'érable 190 ml", categorie: 'tartinade', prix: 11, stock: 9 },
{ id: 5, nom: 'Tire sur la neige', categorie: 'confiserie', prix: 6.25, stock: 30 },
{ id: 6, nom: 'Coffret découverte', categorie: 'coffret', prix: 39, stock: 5 },
];
let prochainId = 7;
/** Attribue un identifiant à un nouveau produit. */
export function nouvelId() {
return prochainId++;
}

Les routes de l’étape 2 y passent presque telles quelles : les adresses perdent leur préfixe, et les recherches répétées deviennent une fonction trouver().

src/routes/produits.js

Les cinq points d'accès de l'API sur les produits.

La spécification
  • Traite les opérations de l’API sur la collection des produits et sur chaque produit.
  • Collection : liste en lecture ; création, réussie avec 201 et l’adresse du nouveau produit.
  • Produit : lecture, remplacement, suppression ; 404 s’il n’existe pas.
  • Création et remplacement : un objet JSON est exigé, sinon 400 ; données refusées : 422, avec les anomalies par champ.
  • Règles : nom obligatoire d’au plus 100 caractères, catégorie de la liste, prix positif, stock entier positif ou nul.
  • Suppression réussie : 204, sans corps.
  • Méthode non prévue pour une adresse connue : 405, avec les méthodes admises.
src/app.js

Crée l'application, branche le routeur des produits, traite les cas restants.

La spécification
  • Crée l’application Express et l’exporte, sans la démarrer.
  • Lit les corps JSON de toutes les requêtes.
  • Confie les adresses qui commencent par /api/produits au routeur des produits.
  • Adresse qu’aucune route ne traite : 404, en JSON.
  • Corps JSON illisible : 400 ; toute autre erreur : 500, sans détail technique pour le client.
import app from './app.js';
const PORT = 3000;
app.listen(PORT, () => {
console.log(`API du catalogue : http://localhost:${PORT}/api/produits`);
});

app.js crée l’application, serveur.js la démarre. Cette séparation peut sembler excessive pour quelques lignes. Elle permettra plus tard de tester l’application sans ouvrir de port, en important app.js seulement.

Ajoutez à l’API une seconde ressource, les commandes, en suivant l’organisation des produits : un module de données src/donnees/commandes.js, un routeur src/routes/commandes.js, branché dans app.js sur /api/commandes.

Une commande a la forme suivante :

{
"id": 1,
"client": "Ana Tremblay",
"lignes": [
{ "produitId": 2, "quantite": 3 },
{ "produitId": 5, "quantite": 1 }
],
"total": 52
}
Méthode Adresse Réponse en cas de succès Erreurs
GET /api/commandes 200, la liste
GET /api/commandes/:id 200, la commande 404
POST /api/commandes 201, la commande et son adresse dans Location 400, 422
DELETE /api/commandes/:id 204 404
  1. Le client envoie seulement client et lignes. Le serveur attribue l’identifiant et calcule le total à partir des prix des produits.
  2. client est obligatoire. lignes est un tableau non vide ; chaque ligne désigne un produit existant, avec une quantité entière d’au moins 1. Toute anomalie produit un 422, avec les clés client ou lignes dans erreurs.
  3. Toute autre méthode répond 405, avec l’en-tête Allow.
  • Une API met les opérations d’un logiciel à la disposition d’autres logiciels. Elle permet plusieurs clients, des rythmes d’évolution séparés et une seule mise en œuvre des règles.
  • Elle applique à l’échelle d’un système les principes déjà vus pour les classes : encapsulation, couplage faible, responsabilité unique. Une API est une interface : le client dépend de ce qu’elle promet, pas de la façon dont elle le tient.
  • Une API web se conçoit en répondant à une série de questions : adresse, méthode, format, en-têtes, statuts, erreurs, authentification, CORS, versionnement, documentation.
  • REST organise l’API autour de ressources désignées par des adresses ; la méthode HTTP porte l’action, chaque requête se suffit à elle-même, et le code de statut dit le résultat.
  • Node.js exécute du JavaScript sur le serveur ; un seul programme reste en marche et traite toutes les requêtes. Express fournit le routage et les objets req et res.
  • express.json() décode le corps JSON dans req.body ; req.params contient les parties variables de l’adresse, sous forme de texte.
  • Une API ne choisit pas ses clients : elle vérifie elle-même ce qu’elle reçoit. Un corps illisible ou absent mérite un 400 ; des valeurs refusées, un 422, avec les anomalies par champ.
  • Un routeur regroupe les routes d’une ressource ; app.js le branche sur un préfixe, puis traite les cas restants : 404, puis les erreurs. router.route() regroupe les méthodes d’une adresse, et .all() répond 405 aux autres.