b) Introduction au JSON et persistance des données

Le JSON, c'est quoi ?

YoutubeImage

Précédemment, nous avons développé notre première RESTful API.
Nous avons implicitement découvert le JSON, notamment lorsque nous avons fait des requêtes vers nos API.

Nous allons maintenant voir ce que permet le JSON, notamment la création de fichiers permettant de sauvegarder des données facilement en JS.

JSON vient de JavaScript Object Notation.

C'est une syntaxe pour échanger et faire persister des données.

Du JSON, c'est du texte en notation JS.

Voici les types de données qui sont valides en JSON :

  • string
  • number
  • object
  • array
  • boolean
  • null

⚡ Il n'y a donc pas de function, date et undefined.

Voici un exemple de représentation de données en JSON qui correspond à ce que très souvent une API renvoie, un array d'objets :

json
[
{
"email": "raphael@voila.com",
"fullname": "Raphael Baroni"
},
{
"email": "jkj@herenqn.com",
"fullname": "JK Roling"
},
{
"email": "serena@gmail.com",
"fullname": "Serena Here"
}
]

Communication de données en JSON à une API

Introduction

Dans le tutoriel précédent, nous avons communiqué des données au format JSON :

  • soit lors des requêtes via REST Client : nous avons envoyé les données permettant de créer ou modifier une pizza :
  • soit lors du traitment des requêtes par l'API, afin de créer ou modifier les pizzas et les sauvegarder en mémoire vive (dans un tableau d'objets).

Nous allons maintenant approfondir comment les données au format JSON ont été traitées par l'API.

Envoi de données d'une API vers un client & sérialisation

Via Express, nous pouvons très facilement convertir un objet JS en JSON afin de l'envoyer vers une application cliente grâce à la méthode res.json().

C'est ce que nous appelons de la sérialisation de données : nous passons du monde "objets en mémoire" vers du texte (ou des octets) qui va voyager sur un réseau.

Le code actuel de notre RESTful API, renvoyant un array de pizzas au format JSON, est géré automatiquement via :

js
res.json(orderedMenu ?? MENU);

Lorsque l'API renvoie MENU avec les pizzas par défaut, voici le JSON qui voyage sur le réseau :

json
[
{
"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"
}
]

💭 La puissance du JSON peut déjà s'exprimer ici. Mais comment ?

L'API renvoie un array d'objets, des pizzas, au format JSON, qui correspond en fait à un format texte avec des conventions.
Il est donc possible à n'importe quelle application cliente d'utiliser ces données, quelque soit la technologie, le langage utilisé pour développer cette application cliente.
Ainsi, par exemple, une application Android, développée en Java, pourrait consommer cette API pour afficher un menu de pizzas !

Réception de données d'un client par une API & parsing

Via Express, nous pouvons très facilement convertir du JSON vers un objet JS à l'aide du middleware express.json().

C'est ce que nous appelons du parsing de données, ou de la désérialisation : nous passons du monde texte / JSON (ou des octets) vers des "objets en mémoire".

Le code actuel de notre RESTful API, récupérant les données d'une pizza à créer, est très simple :

js
const title = req?.body?.title?.length !== 0 ? req.body.title : undefined;
const content = req?.body?.content?.length !== 0 ? req.body.content : undefined;

Automatiquement, grâce à Express et au middleware appelé dans apps.js (app.use(express.json());), req.body contient un objet JS représentant toutes les données JSON qui étaient présentes dans le body de la requête cliente, comme par exemple :

http
### Create a pizza
POST {{baseUrl}}/pizzas
Content-Type: application/json
{
"title":"Magic Green",
"content":"Epinards, Brocolis, Olives vertes, Basilic"
}

Il est important de communiquer le "media type" (ou MIME type) dans le corps de la requête : application/json.
Cela indique à l'application qui est la cible de la requête (l'API dans notre cas) quelle genre d'outil elle devra utiliser pour décoder les données.
Par exemple, il faut faire un traitement différent pour récupérer des données au format JSON que pour récupérer des données directement associées à un fichier image.

Lecture et sauvegarde de données dans un fichier JSON par une API

Lecture de données se trouvant dans un fichier JSON

La fonction JSON.parse(objectSerialized) permet de créer un objet JS à partir d'une string contenant des données au format JSON.

Par exemple, voici une fonction, permettant à une application Express de créer un objet JS en lisant des données se trouvant dans un fichier .json dont le chemin et nom complet sont indiqués dans le paramètre filePath :

js
/**
* Parse items given in a .json file
* @param {String} filePath - path to the .json file
* If the file does not exist or it's content cannot be parsed as JSON data,
* use the default data.
* @param {Array} defaultArray - Content to be used when the .json file does not exists
* @returns {Array} : the array that was parsed from the file (or defaultData)
*/
function parse(filePath, defaultArray = []) {
if (!fs.existsSync(filePath)) return defaultArray;
const fileData = fs.readFileSync(filePath);
try {
// parse() Throws a SyntaxError exception if the string to parse is not valid JSON.
return JSON.parse(fileData);
} catch (err) {
return defaultArray;
}
}

Imaginez que plutôt que de lire le menu de pizza à partir d'un array d'objets, on souhaite lire ce menu grâce au contenu d'un fichier contenant du JSON. Voici ce que donnerait l'opération de lecture de toutes les pizzas si le chemin et nom complet du fichier JSON était donné dans la constante jsonDbPath. :

js
const jsonDbPath = __dirname + '/../data/pizzas.json';
// Read all the pizzas from the menu
router.get('/', function (req, res) {
console.log('GET /pizzas');
const pizzas = parse(jsonDbPath, DEFAULT_MENU);
res.json(pizzas);
});

Sauvegarde de données dans un fichier JSON

La fonction JSON.stringify(objectToSerialised) permet de créer une string contenant la représentation JSON de l'objet à sérialiser.

Côté serveur, il est ensuite facile de sauvegarder les données JSON au sein d'un fichier.

Par exemple, voici une fonction permettant à une application Express de sauvegarder au format JSON un objet dans un fichier .json dont son chemin et nom complet sont indiqués dans le paramètre filePath :

js
/**
* Serialize the content of an Object within a file
* @param {String} filePath - path to the .json file
* @param {Array} object - Object to be written within the .json file.
* Even if the file exists, its whole content is reset by the given object.
*/
function serialize(filePath, object) {
const objectSerialized = JSON.stringify(object);
fs.writeFileSync(filePath, objectSerialized);
}

Imaginez que, au sein du router traitant des ressources de type "pizzas", vous passiez la valeur suivante à filePath :

js
const jsonDbPath = __dirname + '/../data/pizzas.json';
serialize(jsonDbPath, MENU);

Cela signifie que dans le projet contenant notre API, nous allons sauvegarder le menu des pizzas au format JSON dans le fichier JSON /data/pizzas.json.

Ce fichier est en fait une base de données simplifiée !

Persistance des données : d'une sauvegarde en mémoire vive vers un fichier JSON

Nous allons maintenant réaliser un tutoriel pour rendre les ressources de type "pizzas" persistantes.
Nous allons repartir de l'API créée au tutoriel précédent.

Dans votre repo web2, veuillez copier / coller le répertoire /tutorials/pizzeria/api/basic et le renommer en /tutorials/pizzeria/api/persistence.

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

Veuillez ouvrir un terminal au niveau de ce répertoire.

Pour la suite du tutoriel, nous considérons que tous les chemins absolus démarrent du répertoire /tutorials/pizzeria/api/persistence (ou /web2/tutorials/pizzeria/api/persistence si l'on considère le nom du répertoire du repo).

Veuillez créer un nouveau répertoire /utils. Au sein de ce répertoire, veuillez créer le module /utils/json.js dans lequel vous allez ajouter ces fonctions :

js
const fs = require('fs');
/**
* Parse items given in a .json file
* @param {String} filePath - path to the .json file
* If the file does not exist or it's content cannot be parsed as JSON data,
* use the default data.
* @param {Array} defaultArray - Content to be used when the .json file does not exists
* @returns {Array} : the array that was parsed from the file (or defaultArray)
*/
function parse(filePath, defaultArray = []) {
if (!fs.existsSync(filePath)) return defaultArray;
const fileData = fs.readFileSync(filePath);
try {
// parse() Throws a SyntaxError exception if the string to parse is not valid JSON.
return JSON.parse(fileData);
} catch (err) {
return defaultArray;
}
}
/**
* Serialize the content of an Object within a file
* @param {String} filePath - path to the .json file
* @param {Array} object - Object to be written within the .json file.
* Even if the file exists, its whole content is reset by the given object.
*/
function serialize(filePath, object) {
const objectSerialized = JSON.stringify(object);
createPotentialLastDirectory(filePath);
fs.writeFileSync(filePath, objectSerialized);
}
/**
*
* @param {String} filePath - path to the .json file
*/
function createPotentialLastDirectory(filePath) {
const pathToLastDirectory = filePath.substring(0, filePath.lastIndexOf('/'));
if (fs.existsSync(pathToLastDirectory)) return;
fs.mkdirSync(pathToLastDirectory);
}
module.exports = { parse, serialize };

L'opération de sérialisation des données est faite via la fonction serialize de /utils/json.js. Pour se simplifier la vie et ne pas obliger les développeurs à devoir créer manuellement un répertoire qui contiendra la mini DB de pizzas (le fichier pizzas.json dans la suite de l'exemple), une fonction a été créée qui s'appelle createPotentialLastDirectory.
La fonction serialize fait appel à cette fonction qui va, si nécessaire, créer le dernier répertoire donné dans le chemin vers le fichier JSON (le répertoire /data dans la suite de l'exemple).

Il n'est pas intéressant de retenir par coeur le code donné dans /utils/json.js. Par contre, il est important que vous compreniez celui-ci, ce qu'il fait.

A présent, nous allons convertir le code du router de "pizzas" pour rendre les données peristantes.
Voici ce que nous devons faire pour les opérations de :

  • lecture de ressources : il suffit de faire appel à la fonction parse qui tentera de charger les ressources qui devraient se trouver dans le répertoire /data/pizzas.json. Notons que le chemin vers ce fichier JSON est un simple choix, il doit être configurable.
  • écriture de ressources : lors d'une opération d'écriture pour créer une nouvelle ressource, ou pour mettre à jour une ressource existante, voici les étapes :
    • création d'une liste de toutes les pizzas dans un array : cela correspond à l'utilisation de la fonction parse pour tenter de charger ce qui est contenu dans la mini DB de pizzas.
    • mise à jour de l'array soit en ajoutant un nouvel objet (une pizza), soit en modifiant un objet existant, soit en supprimant un objet.
    • réécriture complète du fichier JSON contenant la liste de pizzas sur base de l'array de pizzas qui a précédemment été mis à jour via la méthode serialize.

Voici le code du router mis à jour afin de gérer la persistance selon la stratégie définie ci-dessus, les modifications étant surlignées :

js
1
var express = require('express');
2
const { serialize, parse } = require('../utils/json');
3
var router = express.Router();
4
5
const jsonDbPath = __dirname + '/../data/pizzas.json';
6
7
const MENU = [
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
/* Read all the pizzas from the menu
36
GET /pizzas?order=title : ascending order by title
37
  GET /pizzas?order=-title : descending order by title
38
*/
39
router.get('/', (req, res, next) => {
40
const orderByTitle =
41
req?.query?.order?.includes('title') ? req.query.order : undefined;
42
let orderedMenu;
43
console.log(`order by ${orderByTitle ?? 'not requested'}`);
44
45
const pizzas = parse(jsonDbPath, MENU);
46
47
if (orderByTitle) orderedMenu = [...pizzas].sort((a, b) => a.title.localeCompare(b.title));
48
if (orderByTitle === '-title') orderedMenu = orderedMenu.reverse();
49
50
console.log('GET /pizzas');
51
return res.json(orderedMenu ?? pizzas);
52
});
53
54
// Read the pizza identified by an id in the menu
55
router.get('/:id', (req, res) => {
56
console.log(`GET /pizzas/${req.params.id}`);
57
58
const pizzas = parse(jsonDbPath, MENU);
59
60
const indexOfPizzaFound = pizzas.findIndex(pizza => pizza.id == req.params.id);
61
62
if (indexOfPizzaFound < 0) return res.sendStatus(404);
63
64
return res.json(pizzas[indexOfPizzaFound]);
65
});
66
67
// Create a pizza to be added to the menu.
68
router.post('/', (req, res) => {
69
const title = req?.body?.title?.length !== 0 ? req.body.title : undefined;
70
const content = req?.body?.content?.length !== 0 ? req.body.content : undefined;
71
72
console.log('POST /pizzas');
73
74
if (!title || !content) return res.sendStatus(400); // error code '400 Bad request'
75
76
const pizzas = parse(jsonDbPath, MENU);
77
const lastItemIndex = pizzas?.length !== 0 ? pizzas.length - 1 : undefined;
78
const lastId = lastItemIndex !== undefined ? pizzas[lastItemIndex]?.id : 0;
79
const nextId = lastId + 1;
80
81
const newPizza = {
82
id: nextId,
83
title: title,
84
content: content,
85
};
86
87
pizzas.push(newPizza);
88
89
serialize(jsonDbPath, pizzas);
90
91
return res.json(newPizza);
92
});
93
94
// Delete a pizza from the menu based on its id
95
router.delete('/:id', (req, res) => {
96
console.log(`DELETE /pizzas/${req.params.id}`);
97
98
const pizzas = parse(jsonDbPath, MENU);
99
100
const foundIndex = pizzas.findIndex(pizza => pizza.id == req.params.id);
101
102
if (foundIndex < 0) return res.sendStatus(404);
103
104
const itemsRemovedFromMenu = pizzas.splice(foundIndex, 1);
105
const itemRemoved = itemsRemovedFromMenu[0];
106
107
serialize(jsonDbPath, pizzas);
108
109
return res.json(itemRemoved);
110
});
111
112
// Update a pizza based on its id and new values for its parameters
113
router.patch('/:id', (req, res) => {
114
console.log(`PATCH /pizzas/${req.params.id}`);
115
116
const title = req?.body?.title;
117
const content = req?.body?.content;
118
119
console.log('POST /pizzas');
120
121
if ((!title && !content) || title?.length === 0 || content?.length === 0) return res.sendStatus(400);
122
123
const pizzas = parse(jsonDbPath, MENU);
124
125
const foundIndex = pizzas.findIndex(pizza => pizza.id == req.params.id);
126
127
if (foundIndex < 0) return res.sendStatus(404);
128
129
const updatedPizza = {...pizzas[foundIndex], ...req.body};
130
131
pizzas[foundIndex] = updatedPizza;
132
133
serialize(jsonDbPath, pizzas);
134
135
return res.json(updatedPizza);
136
});
137
138
module.exports = router;

Veuillez mettre à jour votre fichier /router/pizzas.js sur base du code donné et testez le bon fonctionnement de l'application. Faites quelques requêtes pour ajouter et modifier des données et vérifiez, une fois que vous redémarrer votre application, que les données persistent.

Exercice 1.7 : Persistance des données

Vous allez mettre à jour la RESTful API de myMovies afin de rendre les données persistantes dans un fichier JSON : /data/films.json.

Veuillez repartir du code de la solution de votre Exercice 1.6.
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.7.

Veuillez tester toutes les fonctions de la RESTful API pour la collection de films à l'aide de REST Client en copiant les requêtes développées pour l'exercice précédent (fichier films.http du répertoire REST Client). Normalement, il n'y a pas de nouvelles requêtes à écrire, il suffit juste de les exécuter.

Veuillez faire un commit de votre code avec le message suivant : 1.7 : API : persistence.