b) Introduction au JSON et persistance des données
Le JSON, c'est quoi ?
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 boisson :
- soit lors du traitment des requêtes par l'API, afin de créer ou modifier les boissons 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 :
jsreturn res.json(drinks);
Lorsque l'API renvoie drinks
pour les boissons par défaut, voici le JSON qui voyage sur le réseau :
json[{"id": 1,"title": "Coca-Cola","image": "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=","volume": 0.33,"price": 2.5},{"id": 2,"title": "Pepsi","image": "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=","volume": 0.33,"price": 2.5},{"id": 3,"title": "Eau Minérale","image": "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=","volume": 0.5,"price": 1.5},{"id": 4,"title": "Jus d'Orange","image": "https://images.unsplash.com/photo-1600271886742-f049cd451bba?q=80&w=1374&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D","volume": 0.25,"price": 4.5},{"id": 5,"title": "Limonade","image": "https://images.unsplash.com/photo-1583064313642-a7c149480c7e?q=80&w=1430&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D","volume": 0.33,"price": 5}]
💭 La puissance du JSON peut déjà s'exprimer ici. Mais comment ?
L'API renvoie un array d'objets, des boissons, 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 des boissons !
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 boisson à créer, est très simple :
tsconst { title, image, volume, price } = req.body as NewDrink;
Automatiquement, grâce à Express et au middleware appelé dans apps.ts
(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 drinkPOST {{baseUrl}}/drinksContent-Type: application/json{"title":"Virgin Tonic","image":"https://plus.unsplash.com/premium_photo-1668771899398-1cdd763f745e?q=80&w=1374&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D","volume":0.25,"price":4.5}
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) quel 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.
Les génériques en TS
Les génériques en TypeScript permettent de créer des composants réutilisables qui fonctionnent avec différents types tout en maintenant la sécurité de typage.
Un bon exemple d'utilisation de type générique est le code de la fonction parse
donnée dans le fichier /utils/json.ts
:
ts/*** 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<T>(filePath: string, defaultArray: T[] = []): T[] {if (!fs.existsSync(filePath)) return defaultArray;const fileData = fs.readFileSync(filePath, "utf8");try {// parse() Throws a SyntaxError exception if the string to parse is not valid JSON.return JSON.parse(fileData) as T[];} catch (err) {return defaultArray;}}
function parse<T>(...)
: Ici, T
est un paramètre de type générique. Il agit comme un espace réservé pour le type que la fonction utilisera. Par convention, les paramètres de type génériques sont souvent des lettres simples comme T
, U
, V
...
Ensuite, on précise là où on utilise T
: tant en paramètre de la fonction parse
que dans le type de retour de la fonction.
La fonction JSON.parse(fileData)
permet de créer un objet JS à partir d'une string
contenant des données au format JSON.
Lorsqu'on va utiliser la fonction parse<T>
, on pourrait préciser le type de données que l'on attend en retour. Par exemple, si on attend un array d'objets de type Drink
, on pourrait appeler la fonction parse<T>
de la manière suivante :
tsconst drinks = parse<Drink>(jsonDbPath, defaultDrinks);
Néanmoins, TypeScript est capable d'inférer le type de retour de la fonction parse
. Il n'est donc pas nécessaire de préciser le type de retour de la fonction parse
si vous avez précisé le type de l'argument defaultDrinks
. Ce code serait donc valide :
tsconst drinks = parse(jsonDbPath, defaultDrinks);
Lecture de données se trouvant dans un fichier JSON
La fonction parse du fichier /utils/json.ts
, introduite dans les génériques, permet de lire des données se trouvant dans un fichier JSON.
Veuillez créer un nouveau projet /tutorials/back/api/persistence
sur base d'un copier/coller de votre répertoire /tutorials/back/api/basic
.
En cas de souci, vous pouvez télécharger le code du tutoriel précédent ici : basic.
Plutôt que de lire le menu des boissons à partir d'un array d'objets, on souhaite lire ce menu grâce au contenu d'un fichier contenant du JSON.
Veuillez commencer par mettre à jour le router de boissons pour faire un Rename Symbol
de la variable drinks
en defaultDrinks
: pour ce faire, vous pouvez cliquer sur la variable drinks
et appuyer sur F2
(équivalent de clic droit sur la variable, Rename Symbol
) dans le fichier /routes/drinks.ts
.
Ensuite, nous prévoyons que le chemin du futur fichier contenant les boissons sera /data/drinks.json
.
Voici ce que donnerait l'opération de lecture de toutes les boissons si le chemin et nom complet du fichier JSON était donné dans la constante jsonDbPath
. Veuillez mettre à jour votre fichier /routes/drinks.ts
:
ts1// Code existant ...2import path from "node:path";3import { parse } from "../utils/json";4const jsonDbPath = path.join(__dirname, "/../data/drinks.json");56// Suite du code78router.get("/", (req, res) => {9const drinks = parse(jsonDbPath, defaultDrinks);10if (!req.query["budget-max"]) {11// Cannot call req.query.budget-max as "-" is an operator12return res.json(drinks);13}14const budgetMax = Number(req.query["budget-max"]);15const filteredDrinks = drinks.filter((drink) => {16return drink.price <= budgetMax;17});18return res.json(filteredDrinks);19});
Actuellement, le fichier /data/drinks.json
n'existe pas, c'est donc le tableau defaultDrinks
qui est retourné par la fonction parse
.
Sauvegarde de données dans un fichier JSON
La fonction JSON.stringify(objectToSerialised)
permet de créer une string
contenant la représentation JSON d'un objet à sérialiser.
Côté serveur, il est ensuite facile de sauvegarder les données JSON au sein d'un fichier.
Le boilerplate du cours pour une API offre déjà une fonction serialize
(voir fichier /utils/json.ts
) 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
:
ts/*** 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: string, object: object) {const objectSerialized = JSON.stringify(object);createPotentialLastDirectory(filePath);fs.writeFileSync(filePath, objectSerialized);}/**** @param {String} filePath - path to the .json file*/function createPotentialLastDirectory(filePath: string) {const pathToLastDirectory = filePath.substring(0,filePath.lastIndexOf(path.sep));if (fs.existsSync(pathToLastDirectory)) return;fs.mkdirSync(pathToLastDirectory);}
La fonction createPotentialLastDirectory
permet de créer le répertoire qui contiendra le fichier JSON si celui-ci n'existe pas. Par exemple, le répertoire /data
sera créé si le fichier /data/drinks.json
doit être créé et que /data
n'existe pas.
Il n'est pas intéressant de retenir par coeur le code donné dans /utils/json.ts
. 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 "drinks" pour rendre les données persistantes.
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/drinks.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 boissons dans un array : cela correspond à l'utilisation de la fonction
parse
pour tenter de charger ce qui est contenu dans la mini DB de drinks. - mise à jour de l'array soit en ajoutant un nouvel objet (une boisson), soit en modifiant un objet existant, soit en supprimant un objet.
- réécriture complète du fichier JSON contenant la liste des boissons sur base de l'array de boissons qui a précédemment été mis à jour via la méthode
serialize
.
- création d'une liste de toutes les boissons dans un array : cela correspond à l'utilisation de la fonction
Veuillez mettre à jour le code du router /router/drinks.ts
afin de gérer la persistance selon la stratégie définie ci-dessus, les modifications étant surlignées (on a repris les modifications associées à la lecture de toutes les boissons même si nous l'avions déjà vu précédemment) :
ts1import { Router } from "express";2import path from "node:path";3import { Drink, NewDrink } from "../types";4import { parse, serialize } from "../utils/json";5const jsonDbPath = path.join(__dirname, "/../data/drinks.json");67const defaultDrinks: Drink[] = [8{9id: 1,10title: "Coca-Cola",11image:12"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=",13volume: 0.33,14price: 2.5,15},16{17id: 2,18title: "Pepsi",19image:20"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=",21volume: 0.33,22price: 2.5,23},24{25id: 3,26title: "Eau Minérale",27image:28"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=",29volume: 0.5,30price: 1.5,31},32{33id: 4,34title: "Jus d'Orange",35image:36"https://images.unsplash.com/photo-1600271886742-f049cd451bba?q=80&w=1374&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",37volume: 0.25,38price: 4.5,39},40{41id: 5,42title: "Limonade",43image:44"https://images.unsplash.com/photo-1583064313642-a7c149480c7e?q=80&w=1430&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",45volume: 0.33,46price: 5,47},48];4950const router = Router();5152router.get("/", (req, res) => {53const drinks = parse(jsonDbPath, defaultDrinks);54if (!req.query["budget-max"]) {55// Cannot call req.query.budget-max as "-" is an operator56return res.json(drinks);57}58const budgetMax = Number(req.query["budget-max"]);59const filteredDrinks = drinks.filter((drink) => {60return drink.price <= budgetMax;61});62return res.json(filteredDrinks);63});6465router.get("/:id", (req, res) => {66const id = Number(req.params.id);67const drinks = parse(jsonDbPath, defaultDrinks);68const drink = drinks.find((drink) => drink.id === id);69if (!drink) {70return res.sendStatus(404);71}72return res.json(drink);73});7475router.post("/", (req, res) => {76const body: unknown = req.body;77if (78!body ||79typeof body !== "object" ||80!("title" in body) ||81!("image" in body) ||82!("volume" in body) ||83!("price" in body) ||84typeof body.title !== "string" ||85typeof body.image !== "string" ||86typeof body.volume !== "number" ||87typeof body.price !== "number" ||88!body.title.trim() ||89!body.image.trim() ||90body.volume <= 0 ||91body.price <= 092) {93return res.sendStatus(400);94}9596const { title, image, volume, price } = body as NewDrink;9798const drinks = parse(jsonDbPath, defaultDrinks);99100const nextId =101drinks.reduce((maxId, drink) => (drink.id > maxId ? drink.id : maxId), 0) +1021;103104const newDrink: Drink = {105id: nextId,106title,107image,108volume,109price,110};111112drinks.push(newDrink);113serialize(jsonDbPath, drinks);114return res.json(newDrink);115});116117router.delete("/:id", (req, res) => {118const id = Number(req.params.id);119const drinks = parse(jsonDbPath, defaultDrinks);120const index = drinks.findIndex((drink) => drink.id === id);121if (index === -1) {122return res.sendStatus(404);123}124const deletedElements = drinks.splice(index, 1); // splice() returns an array of the deleted elements125serialize(jsonDbPath, drinks);126return res.json(deletedElements[0]);127});128129router.patch("/:id", (req, res) => {130const id = Number(req.params.id);131const drinks = parse(jsonDbPath, defaultDrinks);132const drink = drinks.find((drink) => drink.id === id);133if (!drink) {134return res.sendStatus(404);135}136137const body: unknown = req.body;138139if (140!body ||141typeof body !== "object" ||142("title" in body &&143(typeof body.title !== "string" || !body.title.trim())) ||144("image" in body &&145(typeof body.image !== "string" || !body.image.trim())) ||146("volume" in body &&147(typeof body.volume !== "number" || body.volume <= 0)) ||148("price" in body && (typeof body.price !== "number" || body.price <= 0))149) {150return res.sendStatus(400);151}152153const { title, image, volume, price }: Partial<NewDrink> = body;154155if (title) {156drink.title = title;157}158if (image) {159drink.image = image;160}161if (volume) {162drink.volume = volume;163}164if (price) {165drink.price = price;166}167168serialize(jsonDbPath, drinks);169170return res.json(drink);171});172173export default router;
Veuillez 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 repo git dans /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 : new: ex1.7
.