c) Refactoring à l'aide de services

YoutubeImage

Architectures web possibles pour une API ?

Si nous reprenons le code actuel du tutoriel de l'API gérant des boissons (persistence), nous pouvons détecter que celui-ci pourrait être plus propre.

Par exemple, le routeur de "drinks" s'occupe tant de gérer les requêtes des clients que d'accéder directement aux données.
Généralement, nous préférons séparer le code gérant les accès aux données du code gérant la présentation du résultat des opérations.

On peut donc se demander comment séparer le code traitant de sujets très différents. Il existe une multitude d'architectures possibles, voici par exemple certains des plus grands noms :

  • Architecture MVC (Model View Controller) ; au niveau backend, ce genre d'architecture est généralement très utile quand on fait des MPA (ou Multi-Page Applications) via du Server-Side Rendering. Les Views permettent de générer le frontend à l'aide d'un moteur de templating ; le Controller s'occupe de traiter les requêtes en appelant le Model pour l'accès aux opérations sur les données et en renvoyant les Views adéquates. Pour une API, cette architecture n'est pas la plus adaptée.
  • Architecture classique "three-tier" ; au niveau backend, quand on développe une architecture trois tiers, cela signifie que l'on découpe notre API en trois couches :
    • couche de présentation : cette couche est responsable de présenter les ressources aux clients et d'interpréter les représentations des ressources données dans les requêtes ;
    • couche business : cette couche s'occupe de toute la logique de l'API, elle fait l'intermédiaire entre la couche de présentation et la couche de données ;
    • couche de données : cette couche s'occupe des accès aux données et permet notamment la persistance des ressources au sein de bases de données.
      Le modèle trois tiers est fort intéressant, mais il demande un peu trop d'écriture de codes sans grande valeur ajoutée quand nous utilisons le framework Express.
  • ...

Finalement, parmi les architectures classiques, il n'y a pas réellement une architecture qui colle parfaitement à ce qui est offert par le framework Express, sans devoir écrire du code sans valeur ajoutée.

Ainsi, nous allons simplement créer notre propre architecture "maison", sur base de ces points :

  • On souhaite pouvoir facilement remplacer la couche d'accès aux données sans changer la représentation des ressources ; en effet, dans un premier temps, nous sauvegarderons des données dans des fichiers JSON. Plus tard, si l'on venait à sauvegarder les données dans un système de gestion de base de données, on ne veut pas devoir mettre à jour le code prenant en compte les requêtes des clients et présentant la réponse à ces requêtes ; en gros, on souhaite que le code de nos routers, au sein d'Express, soit indépendant de l'implémentation des accès aux données.
  • Nous n'avons pas vraiment de contraintes pour l'aspect "business" de nos APIs : la logique de l'application peut soit s'associer à la couche de données, soit à la couche présentation. Néanmoins, nous allons préférer associer un maximum de la logique de notre application à ce que nous allons appeler un service. Le service contiendra toutes les opérations possibles sur les ressources, ainsi que les accès aux données.
  • Le service peut être soit écrit en orienté objet, soit simplement être un module fournissant des fonctions. Dans le cadre de ce cours, nous choisissons de présenter un maximum de programmation fonctionnelle plutôt que de l'orienté objet.
    Néanmoins, n'hésitez pas à écrire des classes si cela vous tient à coeur 😉.

Architecture Express & services

Dans un nouveau tutoriel, nous allons maintenant faire un refactor de notre API de gestion des pizzas en créant et utilisant un service.

Veuillez créer un nouveau projet /tutorials/back/api/services sur base d'un copier/coller de votre répertoire /tutorials/back/api/persistence.

En cas de souci, vous pouvez télécharger le code du tutoriel précédent ici : persistence.

Nous allons commencer par créer le service offrant les opérations sur les boissons.
Veuillez créer le fichier /services/drinks.ts.
Au sein de ce fichier, veuillez ajouter le code s'occupant des opérations sur les ressources de type "drinks" :

ts
1
import path from "node:path";
2
import { Drink, NewDrink } from "../types";
3
import { parse, serialize } from "../utils/json";
4
const jsonDbPath = path.join(__dirname, "/../data/drinks.json");
5
6
const defaultDrinks: Drink[] = [
7
{
8
id: 1,
9
title: "Coca-Cola",
10
image:
11
"https://media.istockphoto.com/id/1289738725/fr/photo/bouteille-en-plastique-de-coke-avec-la-conception-et-le-chapeau-rouges-d%C3%A9tiquette.jpg?s=1024x1024&w=is&k=20&c=HBWfROrGDTIgD6fuvTlUq6SrwWqIC35-gceDSJ8TTP8=",
12
volume: 0.33,
13
price: 2.5,
14
},
15
{
16
id: 2,
17
title: "Pepsi",
18
image:
19
"https://media.istockphoto.com/id/185268840/fr/photo/bouteille-de-cola-sur-un-fond-blanc.jpg?s=1024x1024&w=is&k=20&c=xdsxwb4bLjzuQbkT_XvVLyBZyW36GD97T1PCW0MZ4vg=",
20
volume: 0.33,
21
price: 2.5,
22
},
23
{
24
id: 3,
25
title: "Eau Minérale",
26
image:
27
"https://media.istockphoto.com/id/1397515626/fr/photo/verre-deau-gazeuse-%C3%A0-boire-isol%C3%A9.jpg?s=1024x1024&w=is&k=20&c=iEjq6OL86Li4eDG5YGO59d1O3Ga1iMVc_Kj5oeIfAqk=",
28
volume: 0.5,
29
price: 1.5,
30
},
31
{
32
id: 4,
33
title: "Jus d'Orange",
34
image:
35
"https://images.unsplash.com/photo-1600271886742-f049cd451bba?q=80&w=1374&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
36
volume: 0.25,
37
price: 4.5,
38
},
39
{
40
id: 5,
41
title: "Limonade",
42
image:
43
"https://images.unsplash.com/photo-1583064313642-a7c149480c7e?q=80&w=1430&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
44
volume: 0.33,
45
price: 5,
46
},
47
];
48
49
function readAllDrinks(budgetMax: number): Drink[] {
50
const drinks = parse(jsonDbPath, defaultDrinks);
51
if (!budgetMax) {
52
return drinks;
53
}
54
55
const budgetMaxNumber = Number(budgetMax);
56
57
const filteredDrinks = drinks.filter((drink) => {
58
return drink.price <= budgetMaxNumber;
59
});
60
return filteredDrinks;
61
}
62
63
function readOneDrink(id: number): Drink | undefined {
64
const drinks = parse(jsonDbPath, defaultDrinks);
65
const drink = drinks.find((drink) => drink.id === id);
66
if (!drink) {
67
return undefined;
68
}
69
return drink;
70
}
71
72
function createOneDrink(newDrink: NewDrink): Drink {
73
const drinks = parse(jsonDbPath, defaultDrinks);
74
75
const nextId =
76
drinks.reduce((maxId, drink) => (drink.id > maxId ? drink.id : maxId), 0) +
77
1;
78
79
const createdDrink = {
80
id: nextId,
81
...newDrink,
82
};
83
84
drinks.push(createdDrink);
85
serialize(jsonDbPath, drinks);
86
87
return createdDrink;
88
}
89
90
function deleteOneDrink(drinkId: number): Drink | undefined {
91
const drinks = parse(jsonDbPath, defaultDrinks);
92
const index = drinks.findIndex((drink) => drink.id === drinkId);
93
if (index === -1) {
94
return undefined;
95
}
96
97
const deletedElements = drinks.splice(index, 1);
98
serialize(jsonDbPath, drinks);
99
return deletedElements[0];
100
}
101
102
function updateOneDrink(
103
drinkId: number,
104
newDrink: Partial<NewDrink>
105
): Drink | undefined {
106
const drinks = parse(jsonDbPath, defaultDrinks);
107
const drink = drinks.find((drink) => drink.id === drinkId);
108
if (!drink) {
109
return undefined;
110
}
111
112
if (newDrink.title !== undefined) {
113
drink.title = newDrink.title!; // the router already checks for the presence of title
114
}
115
if (newDrink.image !== undefined) {
116
drink.image = newDrink.image!;
117
}
118
if (newDrink.volume !== undefined) {
119
drink.volume = newDrink.volume!;
120
}
121
if (newDrink.price !== undefined) {
122
drink.price = newDrink.price!;
123
}
124
125
serialize(jsonDbPath, drinks);
126
return drink;
127
}
128
129
export {
130
readAllDrinks,
131
readOneDrink,
132
createOneDrink,
133
deleteOneDrink,
134
updateOneDrink,
135
};

Maintenant, nous allons mettre à jour le router /routes/drinks.ts afin de faire les appels aux fonctions offertes par le modèle :

ts
1
import { Router } from "express";
2
import { NewDrink } from "../types";
3
import {
4
createOneDrink,
5
deleteOneDrink,
6
readAllDrinks,
7
readOneDrink,
8
updateOneDrink,
9
} from "../services/drinks";
10
11
const router = Router();
12
13
router.get("/", (req, res) => {
14
const budgetMax = Number(req.query["budget-max"]);
15
const drinks = readAllDrinks(budgetMax);
16
return res.json(drinks);
17
});
18
19
router.get("/:id", (req, res) => {
20
const id = Number(req.params.id);
21
const drink = readOneDrink(id);
22
if (!drink) {
23
return res.sendStatus(404);
24
}
25
return res.json(drink);
26
});
27
28
router.post("/", (req, res) => {
29
const body: unknown = req.body;
30
if (
31
!body ||
32
typeof body !== "object" ||
33
!("title" in body) ||
34
!("image" in body) ||
35
!("volume" in body) ||
36
!("price" in body) ||
37
typeof body.title !== "string" ||
38
typeof body.image !== "string" ||
39
typeof body.volume !== "number" ||
40
typeof body.price !== "number" ||
41
!body.title.trim() ||
42
!body.image.trim() ||
43
body.volume <= 0 ||
44
body.price <= 0
45
) {
46
return res.sendStatus(400);
47
}
48
49
const { title, image, volume, price } = body as NewDrink;
50
51
const newDrink = createOneDrink({ title, image, volume, price });
52
return res.json(newDrink);
53
});
54
55
router.delete("/:id", (req, res) => {
56
const id = Number(req.params.id);
57
const deletedDrink = deleteOneDrink(id);
58
if (!deletedDrink) {
59
return res.sendStatus(404);
60
}
61
return res.json(deletedDrink);
62
});
63
64
router.patch("/:id", (req, res) => {
65
const id = Number(req.params.id);
66
67
const body: unknown = req.body;
68
69
if (
70
!body ||
71
typeof body !== "object" ||
72
("title" in body &&
73
(typeof body.title !== "string" || !body.title.trim())) ||
74
("image" in body &&
75
(typeof body.image !== "string" || !body.image.trim())) ||
76
("volume" in body &&
77
(typeof body.volume !== "number" || body.volume <= 0)) ||
78
("price" in body && (typeof body.price !== "number" || body.price <= 0))
79
) {
80
return res.sendStatus(400);
81
}
82
83
const { title, image, volume, price }: Partial<NewDrink> = body;
84
85
const updatedDrink = updateOneDrink(id, { title, image, volume, price });
86
87
if (!updatedDrink) {
88
return res.sendStatus(404);
89
}
90
91
return res.json(updatedDrink);
92
});
93
94
export default router;

Lancer votre API soit via le debugger, soit via la commande npm run dev.
Veuillez ensuite tester que les opérations sur les boissons fonctionnent bien en exécutant les requêtes HTTP déjà présentes dans le répertoire REST Client du boilerplate.

Maintenant, nous allons aussi mettre à jour l'architecture pour traiter des ressources de type "pizzas". Veuillez créer un fichier /services/pizzas.ts :

ts
1
// Create the pizzas service based on the drinks.ts service
2
import path from "node:path";
3
import { Pizza, NewPizza } from "../types";
4
import { parse, serialize } from "../utils/json";
5
const jsonDbPath = path.join(__dirname, "/../data/pizzas.json");
6
7
const defaultPizzas: Pizza[] = [
8
{
9
id: 1,
10
title: "4 fromages",
11
content: "Gruyère, Sérac, Appenzel, Gorgonzola, Tomates",
12
},
13
{
14
id: 2,
15
title: "Vegan",
16
content: "Tomates, Courgettes, Oignons, Aubergines, Poivrons",
17
},
18
{
19
id: 3,
20
title: "Vegetarian",
21
content: "Mozarella, Tomates, Oignons, Poivrons, Champignons, Olives",
22
},
23
{
24
id: 4,
25
title: "Alpage",
26
content: "Gruyère, Mozarella, Lardons, Tomates",
27
},
28
{
29
id: 5,
30
title: "Diable",
31
content: "Tomates, Mozarella, Chorizo piquant, Jalapenos",
32
},
33
];
34
35
function readAllPizzas(order: string | undefined): Pizza[] {
36
const orderByTitle = order && order.includes("title") ? order : undefined;
37
38
let orderedMenu: Pizza[] = [];
39
const pizzas = parse(jsonDbPath, defaultPizzas);
40
if (orderByTitle)
41
orderedMenu = [...pizzas].sort((a, b) => a.title.localeCompare(b.title));
42
43
if (orderByTitle === "-title") orderedMenu = orderedMenu.reverse();
44
45
return orderedMenu.length === 0 ? pizzas : orderedMenu;
46
}
47
48
function readPizzaById(id: number): Pizza | undefined {
49
const pizzas = parse(jsonDbPath, defaultPizzas);
50
return pizzas.find((pizza) => pizza.id === id);
51
}
52
53
function createPizza(newPizza: NewPizza): Pizza {
54
const pizzas = parse(jsonDbPath, defaultPizzas);
55
const lastId = pizzas[pizzas.length - 1].id;
56
const pizza: Pizza = { id: lastId + 1, ...newPizza };
57
const updatedPizzas = [...pizzas, pizza];
58
serialize(jsonDbPath, updatedPizzas);
59
return pizza;
60
}
61
62
function deletePizza(id: number): Pizza | undefined {
63
const pizzas = parse(jsonDbPath, defaultPizzas);
64
const index = pizzas.findIndex((pizza) => pizza.id === id);
65
if (index === -1) return undefined;
66
67
const deletedElements = pizzas.splice(index, 1);
68
serialize(jsonDbPath, pizzas);
69
return deletedElements[0];
70
}
71
72
function updatePizza(
73
id: number,
74
updatedPizza: Partial<NewPizza>
75
): Pizza | undefined {
76
const pizzas = parse(jsonDbPath, defaultPizzas);
77
const pizza = pizzas.find((pizza) => pizza.id === id);
78
if (!pizza) return undefined;
79
80
if (updatedPizza.title !== undefined) {
81
pizza.title = updatedPizza.title;
82
}
83
if (updatedPizza.content !== undefined) {
84
pizza.content = updatedPizza.content;
85
}
86
87
serialize(jsonDbPath, pizzas);
88
return pizza;
89
}
90
91
export { readAllPizzas, readPizzaById, createPizza, deletePizza, updatePizza };

Et le router /routes/pizzas.ts doit aussi être mis à jour :

ts
1
import { Router } from "express";
2
3
import { NewPizza, PizzaToUpdate } from "../types";
4
import {
5
createPizza,
6
deletePizza,
7
readAllPizzas,
8
readPizzaById,
9
updatePizza,
10
} from "../services/pizzas";
11
12
const router = Router();
13
14
router.get("/error", (_req, _res, _next) => {
15
throw new Error("This is an error");
16
// equivalent of next(new Error("This is an error"));
17
});
18
19
/* Read all the pizzas from the menu
20
GET /pizzas?order=title : ascending order by title
21
GET /pizzas?order=-title : descending order by title
22
*/
23
router.get("/", (req, res) => {
24
if (req.query.order && typeof req.query.order !== "string") {
25
return res.sendStatus(400);
26
}
27
28
const pizzas = readAllPizzas(req.query.order);
29
return res.json(pizzas);
30
});
31
32
// Read the pizza identified by an id in the menu
33
router.get("/:id", (req, res) => {
34
const id = Number(req.params.id);
35
const pizza = readPizzaById(id);
36
if (!pizza) return res.sendStatus(404);
37
return res.json(pizza);
38
});
39
40
// Create a pizza to be added to the menu.
41
router.post("/", (req, res) => {
42
const body: unknown = req.body;
43
if (
44
!body ||
45
typeof body !== "object" ||
46
!("title" in body) ||
47
!("content" in body) ||
48
typeof body.title !== "string" ||
49
typeof body.content !== "string" ||
50
!body.title.trim() ||
51
!body.content.trim()
52
) {
53
return res.sendStatus(400);
54
}
55
56
const { title, content } = body as NewPizza;
57
58
const addedPizza = createPizza({ title, content });
59
60
return res.json(addedPizza);
61
});
62
63
// Delete a pizza from the menu based on its id
64
router.delete("/:id", (req, res) => {
65
const id = Number(req.params.id);
66
const deletedPizza = deletePizza(id);
67
if (!deletedPizza) return res.sendStatus(404);
68
69
return res.json(deletedPizza);
70
});
71
72
// Update a pizza based on its id and new values for its parameters
73
router.patch("/:id", (req, res) => {
74
const body: unknown = req.body;
75
if (
76
!body ||
77
typeof body !== "object" ||
78
("title" in body &&
79
(typeof body.title !== "string" || !body.title.trim())) ||
80
("content" in body &&
81
(typeof body.content !== "string" || !body.content.trim()))
82
) {
83
return res.sendStatus(400);
84
}
85
86
const pizzaToUpdate: PizzaToUpdate = body;
87
88
const id = Number(req.params.id);
89
const updatedPizza = updatePizza(id, pizzaToUpdate);
90
if (!updatedPizza) return res.sendStatus(404);
91
92
return res.json(updatedPizza);
93
});
94
95
export default router;

Lancer votre API soit via le debugger, soit via la commande npm run dev.
Veuillez ensuite tester que tout fonctionne bien en exécutant quelques requêtes HTTP associées aux pizzas et déjà présentes dans le répertoire REST Client du boilerplate.

En cas de souci, vous pouvez accéder au code du tutoriel ici : services.

Exercice 1.8 : Refactoring à l'aide d'un service

Vous allez faire un nouveau refactor de la RESTful API de myMovies, afin de restructurer l'application selon l'architecture recommandée, en utilisant un service pour gérer les opérations sur les films.

Veuillez repartir du code de la solution de votre Exercice 1.7.
Le code de votre application doit se trouver dans votre repo git dans /exercises/1.8.

Veuillez faire un refactor de votre API gérant les films afin que tout ce qui traite des opérations sur les ressources soit fait au sein du service /services/films.ts.

Veuillez tester que votre API fonctionne toujours aussi bien après le refactoring.

Veuillez faire un commit de votre code avec le message suivant : new: ex1.8.

Exercice 1.9 : Encore un service

Vous allez créer une nouvelle API mettant à disposition des opérations CRUD (Create, Read, Update & Delete) sur des ressources de type "texte à dactylographier".

Vous devez appliquer les outils de développement et l'architecture recommandée dans ce cours-ci en mettant en place un service.

Pour ce faire, veuillez créer un nouveau projet dans votre repo git dans /exercises/1.9 sur base du boilerplate basic-ts-api-boilerplate ou sur base de votre exercice précédent (/exercises/1.8).

⚡ Si vous avez fait un clone du boilerplate, attention au Git dans le Git, n'oubliez pas de supprimer le dossier .git présent dans votre nouveau projet.

Un texte à dactylographier contient comme propriétés :

  • id : un uuid généré via la librairie https://www.npmjs.com/package/uuid ;
  • content : un contenu textuel ;
  • level : le niveau associé au texte; les seules valeurs autorisées sont : easy, medium et hard.

Voici le tableau formalisant toutes les opérations que vous devez implémenter :

URIMéthodeMéthode
textsGETREAD ALL : Lire toutes les ressources de la collection
texts?level=valueGETREAD ALL FILTERED : Lire toutes les ressources de la collection selon le filtre donné
texts/:idGETREAD ONE : Lire la ressource identifiée
textsPOSTCREATE ONE : Créer une ressource basée sur les données de la requête
texts/:idDELETEDELETE ONE : Effacer la ressource identifiée
texts/:idPUTUPDATE ONE : Remplacer l'entièreté de la ressource par les données de la requête

Veuillez bien valider les valeurs des paramètres. Notamment, une level doit être compris dans les valeurs autorisées, sinon un code d'erreur approprié doit être renvoyé.

Veuillez tester toutes les méthodes offertes par votre application à l'aide du client HTTP de REST Client.

🤝 Tips

Veuillez faire un commit de votre code avec le message suivant : new: ex1.9.