d) Refactoring à l'aide d'un "fat model"

YoutubeImage

Architectures web possibles pour une API ?

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

Par exemple, le routeur de "pizzas" 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 nivau 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 fat model. Le fat model contiendra toutes les opérations possibles sur les ressources, ainsi que les accès aux données.
  • Le fat model 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 & "fat model"

Voici l'architecture que nous allons appliquer dans nos prochaines API :

GatsbyImage
Architecture recommandée pour ce cours

Voici quelques explications sur ce diagramme que nous allons appliquer dans le prochain tutoriel :

  1. Un client fait la requête à l'API demandant de lire toutes les pizzas.
  2. Le router de "pizzas" prend le rôle de "Controller". Il s'occupe de traiter de la requête et d'appeler une opération du fat model pour accéder aux ressources.
  3. Le fat model s'occupe d'accéder aux données, qui se trouvent au sein d'un fichier JSON, et de les lire.
  4. Le fat model retourne des données sous forme d'un objet JS au router de "pizzas".
  5. Le router de "pizzas" renvoient une représentation JSON de l'objet JS, un array de pizzas, au client.

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

Au sein de votre repo web2, à l'aide du boilerplate du cours basic-api-boilerplate, veuillez créer le projet nommé /web2/tutorials/pizzeria/api/fat-model.

Si vous ne voyez pas comment utiliser le boilerplate, tout est expliqué dans le README associé au repository du boilerplate. N'hésitez pas à le (re)lire ; )

Pour la suite du tutoriel, nous considérons que tous les chemins absolus démarrent du répertoire /web2/tutorials/pizzeria/api/fat-model.

Nous allons commencer par créer le fat model offrant les opérations sur les pizzas.
Veuillez créer le fichier /models/pizzas.js.
Au sein de ce fichier, veuillez ajouter le code s'occupant des opérations sur les ressources de type "pizzas" :

js
const path = require('node:path');
const { parse, serialize } = require('../utils/json');
const jsonDbPath = path.join(__dirname, '/../data/pizzas.json');
const defaultPizzas = [
{
id: 1,
title: '4 fromages',
content: 'Gruyère, Sérac, Appenzel, Gorgonzola, Tomates',
},
{
id: 2,
title: 'Vegan',
content: 'Tomates, Courgettes, Oignons, Aubergines, Poivrons',
},
{
id: 3,
title: 'Vegetarian',
content: 'Mozarella, Tomates, Oignons, Poivrons, Champignons, Olives',
},
{
id: 4,
title: 'Alpage',
content: 'Gruyère, Mozarella, Lardons, Tomates',
},
{
id: 5,
title: 'Diable',
content: 'Tomates, Mozarella, Chorizo piquant, Jalapenos',
},
];
function readAllPizzas(orderBy) {
const orderByTitle = orderBy?.includes('title') ? orderBy : undefined;
let orderedMenu;
const pizzas = parse(jsonDbPath, defaultPizzas);
if (orderByTitle)
orderedMenu = [...pizzas].sort((a, b) => a.title.localeCompare(b.title));
if (orderByTitle === '-title') orderedMenu = orderedMenu.reverse();
const allPizzasPotentiallyOrderd = orderedMenu ?? pizzas;
return allPizzasPotentiallyOrderd;
}
function readOnePizza(id) {
const idNumber = parseInt(id, 10);
const pizzas = parse(jsonDbPath, defaultPizzas);
const indexOfPizzaFound = pizzas.findIndex((pizza) => pizza.id === idNumber);
if (indexOfPizzaFound < 0) return undefined;
return pizzas[indexOfPizzaFound];
}
function createOnePizza(title, content) {
const pizzas = parse(jsonDbPath, defaultPizzas);
const createdPizza = {
id: getNextId(),
title,
content,
};
pizzas.push(createdPizza);
serialize(jsonDbPath, pizzas);
return createdPizza;
}
function getNextId() {
const pizzas = parse(jsonDbPath, defaultPizzas);
const lastItemIndex = pizzas?.length !== 0 ? pizzas.length - 1 : undefined;
if (lastItemIndex === undefined) return 1;
const lastId = pizzas[lastItemIndex]?.id;
const nextId = lastId + 1;
return nextId;
}
function deleteOnePizza(id) {
const idNumber = parseInt(id, 10);
const pizzas = parse(jsonDbPath, defaultPizzas);
const foundIndex = pizzas.findIndex((pizza) => pizza.id === idNumber);
if (foundIndex < 0) return undefined;
const deletedPizzas = pizzas.splice(foundIndex, 1);
const deletedPizza = deletedPizzas[0];
serialize(jsonDbPath, pizzas);
return deletedPizza;
}
function updateOnePizza(id, propertiesToUpdate) {
const idNumber = parseInt(id, 10);
const pizzas = parse(jsonDbPath, defaultPizzas);
const foundIndex = pizzas.findIndex((pizza) => pizza.id === idNumber);
if (foundIndex < 0) return undefined;
const updatedPizza = { ...pizzas[foundIndex], ...propertiesToUpdate };
pizzas[foundIndex] = updatedPizza;
serialize(jsonDbPath, pizzas);
return updatedPizza;
}
module.exports = {
readAllPizzas,
readOnePizza,
createOnePizza,
deleteOnePizza,
updateOnePizza,
};

Maintenant, il ne reste plus qu'à mettre à jour le router /routes/pizzas.js afin de faire les appels aux fonctions offertes par le modèle :

js
1
const express = require('express');
2
const {
3
readAllPizzas,
4
readOnePizza,
5
createOnePizza,
6
deleteOnePizza,
7
updateOnePizza,
8
} = require('../models/pizzas');
9
10
const router = express.Router();
11
12
/* Read all the pizzas from the menu
13
GET /pizzas?order=title : ascending order by title
14
GET /pizzas?order=-title : descending order by title
15
*/
16
router.get('/', (req, res) => {
17
const allPizzasPotentiallyOrdered = readAllPizzas(req?.query?.order);
18
19
return res.json(allPizzasPotentiallyOrdered);
20
});
21
22
// Read the pizza identified by an id in the menu
23
router.get('/:id', (req, res) => {
24
const foundPizza = readOnePizza(req.params.id);
25
26
if (!foundPizza) return res.sendStatus(404);
27
28
return res.json(foundPizza);
29
});
30
31
// Create a pizza to be added to the menu.
32
router.post('/', (req, res) => {
33
const title = req?.body?.title?.length !== 0 ? req.body.title : undefined;
34
const content =
35
req?.body?.content?.length !== 0 ? req.body.content : undefined;
36
37
if (!title || !content) return res.sendStatus(400); // error code '400 Bad request'
38
39
const createdPizza = createOnePizza(title, content);
40
41
return res.json(createdPizza);
42
});
43
44
// Delete a pizza from the menu based on its id
45
router.delete('/:id', (req, res) => {
46
const deletedPizza = deleteOnePizza(req.params.id);
47
48
if (!deletedPizza) return res.sendStatus(404);
49
50
return res.json(deletedPizza);
51
});
52
53
// Update a pizza based on its id and new values for its parameters
54
router.patch('/:id', (req, res) => {
55
const title = req?.body?.title;
56
const content = req?.body?.content;
57
58
if ((!title && !content) || title?.length === 0 || content?.length === 0) {
59
return res.sendStatus(400);
60
}
61
62
const updatedPizza = updateOnePizza(req.params.id, { title, content });
63
64
if (!updatedPizza) return res.sendStatus(404);
65
66
return res.json(updatedPizza);
67
});
68
69
module.exports = 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 les requêtes HTTP déjà présentes dans le répertoire REST Client du boilerplate.

Si tout fonctionne bien, faites un commit de votre repo (web2) avec comme message : api-fat-model tutorial.

En cas de souci, vous pouvez accéder au code du tutoriel ici : api-fat-model.

Exercice 1.9 : Refactoring à l'aide d'un fat model

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 "fat model" pour gérer les opérations sur les films.

Veuillez repartir du code de la solution de votre Exercice 1.8.
Le code de votre application doit se trouver dans votre repository local et votre web repository (normalement appelé web2) dans le répertoire nommé /exercises/1.9.

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 modèle /models/films.js.

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

Veuillez faire un commit de votre code avec le message suivant : 1.9 : API : fat model.

Exercice 1.10 : Encore un fat model

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 "fat model".

Pour ce faire, veuillez créer un nouveau projet dans votre repository local et votre web repository (normalement appelé web2) nommé /exercises/1.10 sur base du boilerplate : basic-api-boilerplate.

⚡ 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. Notament, 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.

Veuillez faire un commit de votre code avec le message suivant : 1.10 : API : CRUD texts & fat model.