c) Refactoring à l'aide de services
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" :
ts1import path from "node:path";2import { Drink, NewDrink } from "../types";3import { parse, serialize } from "../utils/json";4const jsonDbPath = path.join(__dirname, "/../data/drinks.json");56const defaultDrinks: Drink[] = [7{8id: 1,9title: "Coca-Cola",10image: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=",12volume: 0.33,13price: 2.5,14},15{16id: 2,17title: "Pepsi",18image: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=",20volume: 0.33,21price: 2.5,22},23{24id: 3,25title: "Eau Minérale",26image: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=",28volume: 0.5,29price: 1.5,30},31{32id: 4,33title: "Jus d'Orange",34image: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",36volume: 0.25,37price: 4.5,38},39{40id: 5,41title: "Limonade",42image: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",44volume: 0.33,45price: 5,46},47];4849function readAllDrinks(budgetMax: number): Drink[] {50const drinks = parse(jsonDbPath, defaultDrinks);51if (!budgetMax) {52return drinks;53}5455const budgetMaxNumber = Number(budgetMax);5657const filteredDrinks = drinks.filter((drink) => {58return drink.price <= budgetMaxNumber;59});60return filteredDrinks;61}6263function readOneDrink(id: number): Drink | undefined {64const drinks = parse(jsonDbPath, defaultDrinks);65const drink = drinks.find((drink) => drink.id === id);66if (!drink) {67return undefined;68}69return drink;70}7172function createOneDrink(newDrink: NewDrink): Drink {73const drinks = parse(jsonDbPath, defaultDrinks);7475const nextId =76drinks.reduce((maxId, drink) => (drink.id > maxId ? drink.id : maxId), 0) +771;7879const createdDrink = {80id: nextId,81...newDrink,82};8384drinks.push(createdDrink);85serialize(jsonDbPath, drinks);8687return createdDrink;88}8990function deleteOneDrink(drinkId: number): Drink | undefined {91const drinks = parse(jsonDbPath, defaultDrinks);92const index = drinks.findIndex((drink) => drink.id === drinkId);93if (index === -1) {94return undefined;95}9697const deletedElements = drinks.splice(index, 1);98serialize(jsonDbPath, drinks);99return deletedElements[0];100}101102function updateOneDrink(103drinkId: number,104newDrink: Partial<NewDrink>105): Drink | undefined {106const drinks = parse(jsonDbPath, defaultDrinks);107const drink = drinks.find((drink) => drink.id === drinkId);108if (!drink) {109return undefined;110}111112if (newDrink.title !== undefined) {113drink.title = newDrink.title!; // the router already checks for the presence of title114}115if (newDrink.image !== undefined) {116drink.image = newDrink.image!;117}118if (newDrink.volume !== undefined) {119drink.volume = newDrink.volume!;120}121if (newDrink.price !== undefined) {122drink.price = newDrink.price!;123}124125serialize(jsonDbPath, drinks);126return drink;127}128129export {130readAllDrinks,131readOneDrink,132createOneDrink,133deleteOneDrink,134updateOneDrink,135};
Maintenant, nous allons mettre à jour le router /routes/drinks.ts
afin de faire les appels aux fonctions offertes par le modèle :
ts1import { Router } from "express";2import { NewDrink } from "../types";3import {4createOneDrink,5deleteOneDrink,6readAllDrinks,7readOneDrink,8updateOneDrink,9} from "../services/drinks";1011const router = Router();1213router.get("/", (req, res) => {14const budgetMax = Number(req.query["budget-max"]);15const drinks = readAllDrinks(budgetMax);16return res.json(drinks);17});1819router.get("/:id", (req, res) => {20const id = Number(req.params.id);21const drink = readOneDrink(id);22if (!drink) {23return res.sendStatus(404);24}25return res.json(drink);26});2728router.post("/", (req, res) => {29const body: unknown = req.body;30if (31!body ||32typeof body !== "object" ||33!("title" in body) ||34!("image" in body) ||35!("volume" in body) ||36!("price" in body) ||37typeof body.title !== "string" ||38typeof body.image !== "string" ||39typeof body.volume !== "number" ||40typeof body.price !== "number" ||41!body.title.trim() ||42!body.image.trim() ||43body.volume <= 0 ||44body.price <= 045) {46return res.sendStatus(400);47}4849const { title, image, volume, price } = body as NewDrink;5051const newDrink = createOneDrink({ title, image, volume, price });52return res.json(newDrink);53});5455router.delete("/:id", (req, res) => {56const id = Number(req.params.id);57const deletedDrink = deleteOneDrink(id);58if (!deletedDrink) {59return res.sendStatus(404);60}61return res.json(deletedDrink);62});6364router.patch("/:id", (req, res) => {65const id = Number(req.params.id);6667const body: unknown = req.body;6869if (70!body ||71typeof 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) {80return res.sendStatus(400);81}8283const { title, image, volume, price }: Partial<NewDrink> = body;8485const updatedDrink = updateOneDrink(id, { title, image, volume, price });8687if (!updatedDrink) {88return res.sendStatus(404);89}9091return res.json(updatedDrink);92});9394export 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
:
ts1// Create the pizzas service based on the drinks.ts service2import path from "node:path";3import { Pizza, NewPizza } from "../types";4import { parse, serialize } from "../utils/json";5const jsonDbPath = path.join(__dirname, "/../data/pizzas.json");67const defaultPizzas: Pizza[] = [8{9id: 1,10title: "4 fromages",11content: "Gruyère, Sérac, Appenzel, Gorgonzola, Tomates",12},13{14id: 2,15title: "Vegan",16content: "Tomates, Courgettes, Oignons, Aubergines, Poivrons",17},18{19id: 3,20title: "Vegetarian",21content: "Mozarella, Tomates, Oignons, Poivrons, Champignons, Olives",22},23{24id: 4,25title: "Alpage",26content: "Gruyère, Mozarella, Lardons, Tomates",27},28{29id: 5,30title: "Diable",31content: "Tomates, Mozarella, Chorizo piquant, Jalapenos",32},33];3435function readAllPizzas(order: string | undefined): Pizza[] {36const orderByTitle = order && order.includes("title") ? order : undefined;3738let orderedMenu: Pizza[] = [];39const pizzas = parse(jsonDbPath, defaultPizzas);40if (orderByTitle)41orderedMenu = [...pizzas].sort((a, b) => a.title.localeCompare(b.title));4243if (orderByTitle === "-title") orderedMenu = orderedMenu.reverse();4445return orderedMenu.length === 0 ? pizzas : orderedMenu;46}4748function readPizzaById(id: number): Pizza | undefined {49const pizzas = parse(jsonDbPath, defaultPizzas);50return pizzas.find((pizza) => pizza.id === id);51}5253function createPizza(newPizza: NewPizza): Pizza {54const pizzas = parse(jsonDbPath, defaultPizzas);55const lastId = pizzas[pizzas.length - 1].id;56const pizza: Pizza = { id: lastId + 1, ...newPizza };57const updatedPizzas = [...pizzas, pizza];58serialize(jsonDbPath, updatedPizzas);59return pizza;60}6162function deletePizza(id: number): Pizza | undefined {63const pizzas = parse(jsonDbPath, defaultPizzas);64const index = pizzas.findIndex((pizza) => pizza.id === id);65if (index === -1) return undefined;6667const deletedElements = pizzas.splice(index, 1);68serialize(jsonDbPath, pizzas);69return deletedElements[0];70}7172function updatePizza(73id: number,74updatedPizza: Partial<NewPizza>75): Pizza | undefined {76const pizzas = parse(jsonDbPath, defaultPizzas);77const pizza = pizzas.find((pizza) => pizza.id === id);78if (!pizza) return undefined;7980if (updatedPizza.title !== undefined) {81pizza.title = updatedPizza.title;82}83if (updatedPizza.content !== undefined) {84pizza.content = updatedPizza.content;85}8687serialize(jsonDbPath, pizzas);88return pizza;89}9091export { readAllPizzas, readPizzaById, createPizza, deletePizza, updatePizza };
Et le router /routes/pizzas.ts
doit aussi être mis à jour :
ts1import { Router } from "express";23import { NewPizza, PizzaToUpdate } from "../types";4import {5createPizza,6deletePizza,7readAllPizzas,8readPizzaById,9updatePizza,10} from "../services/pizzas";1112const router = Router();1314router.get("/error", (_req, _res, _next) => {15throw new Error("This is an error");16// equivalent of next(new Error("This is an error"));17});1819/* Read all the pizzas from the menu20GET /pizzas?order=title : ascending order by title21GET /pizzas?order=-title : descending order by title22*/23router.get("/", (req, res) => {24if (req.query.order && typeof req.query.order !== "string") {25return res.sendStatus(400);26}2728const pizzas = readAllPizzas(req.query.order);29return res.json(pizzas);30});3132// Read the pizza identified by an id in the menu33router.get("/:id", (req, res) => {34const id = Number(req.params.id);35const pizza = readPizzaById(id);36if (!pizza) return res.sendStatus(404);37return res.json(pizza);38});3940// Create a pizza to be added to the menu.41router.post("/", (req, res) => {42const body: unknown = req.body;43if (44!body ||45typeof body !== "object" ||46!("title" in body) ||47!("content" in body) ||48typeof body.title !== "string" ||49typeof body.content !== "string" ||50!body.title.trim() ||51!body.content.trim()52) {53return res.sendStatus(400);54}5556const { title, content } = body as NewPizza;5758const addedPizza = createPizza({ title, content });5960return res.json(addedPizza);61});6263// Delete a pizza from the menu based on its id64router.delete("/:id", (req, res) => {65const id = Number(req.params.id);66const deletedPizza = deletePizza(id);67if (!deletedPizza) return res.sendStatus(404);6869return res.json(deletedPizza);70});7172// Update a pizza based on its id and new values for its parameters73router.patch("/:id", (req, res) => {74const body: unknown = req.body;75if (76!body ||77typeof 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) {83return res.sendStatus(400);84}8586const pizzaToUpdate: PizzaToUpdate = body;8788const id = Number(req.params.id);89const updatedPizza = updatePizza(id, pizzaToUpdate);90if (!updatedPizza) return res.sendStatus(404);9192return res.json(updatedPizza);93});9495export 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 :
URI | Méthode | Méthode |
---|---|---|
texts | GET | READ ALL : Lire toutes les ressources de la collection |
texts?level=value | GET | READ ALL FILTERED : Lire toutes les ressources de la collection selon le filtre donné |
texts/:id | GET | READ ONE : Lire la ressource identifiée |
texts | POST | CREATE ONE : Créer une ressource basée sur les données de la requête |
texts/:id | DELETE | DELETE ONE : Effacer la ressource identifiée |
texts/:id | PUT | UPDATE 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
- Pour utiliser la librairie
uuid
, consultez la documentation en ligne : https://www.npmjs.com/package/uuid - Si vous avez oublié d'installer les définitions de type pour
uuid
, vous pouvez le faire via la commandenpm install --save-dev @types/uuid
. Pour rappel, cela est expliqué dans l'Introduction aux packages Node.js & npm (voir "Installer un package").
Veuillez faire un commit
de votre code avec le message suivant : new: ex1.9
.