g) Les requêtes & promises
Fetch de données
La méthode fetch
permet de faire des requêtes HTTP d'un browser vers des API.
Cette méthode est asynchrone, c'est-à-dire quelle n'est pas bloquante, elle renvoie des promesses de résultats via des objets Promise
. Dans un premier temps, nous allons de manière intuitive découvrir la programmation asynchrone. Nous verrons plus en détails par la suite ce type de programmation à l'aide de promises (les promesses).
Si un jour vous avez besoin d'en savoir plus sur la méthode fetch
, n'hésitez pas à consulter la documentation MDN [R.61].
Pour ce tutoriel, veuillez créer une copie du tutoriel routing-state
, si nécessaire voici le code du tutoriel routing-state, et l'appeler fetch-no-proxy
. Changez le nom du projet dans package.json
.
Actuellement, les pizzas
du menu sont "hardcodées" dans App
.
Nous souhaitons changer ça : afin de récupérer une liste de pizzas, l'IHM doit faire une requête fetch
à notre RESTful API développée dans la partie Refactoring à l'aide de services :
services.
Nous n'allons donc plus utiliser REST Client mais une fonction offerte par le browser pour faire l'équivalent de cette requête :
http### Read all pizzas with File variableGET {{baseUrl}}/pizzas
Veuillez mettre à jour App
en supprimant l'array pizzas
et en rajoutant la requête fetch
:
tsx1const App = () => {2const [actionToBePerformed, setActionToBePerformed] = useState(false);3const [pizzas, setPizzas] = useState<Pizza[]>([]);45useEffect(() => {6fetch("http://localhost:3000/pizzas")7.then((response) => {8if (!response.ok)9throw new Error(10`fetch error : ${response.status} : ${response.statusText}`11);12return response.json();13})14.then((pizzas) => setPizzas(pizzas))15.catch((err) => {16console.error("HomePage::error: ", err);17});18}, []);1920// Reste du code inchangé
Malheureusement, cela ne fonctionne pas, nous avons cette erreur : Access to fetch at 'http://localhost:3000/pizzas' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
.
Nous allons apprendre à résoudre cette erreur dans la partie qui suit, car celle-ci dépend de la façon dont l'API a été configurée. A ce stade-ci, pour voir l'application fonctionner, veuillez :
- Stopper la RESTful API ;
- Télécharger, et désarchiver cette API : RESTful API offerte grâce à json-server
- Lancer l'API téléchargée :
- Ouvrir un terminal dans son répertoire.
- Installation des packages :
npm i
- Exécution de l'API :
npm start
- Faire un refresh au niveau de votre browser.
Le menu des pizzas devrait s'afficher 🎉.
Le code va être expliqué dans la suite du tutoriel.
Si nécessaire, vous pouvez trouver le code associé à ce tutoriel ici : fetch-no-proxy.
useEffect
Mais à quoi sert useEffect
dans un composant React ?
useEffect
est un hook de React qui permet d'exécuter des effets secondaires dans nos composants fonctionnels. Les effets secondaires peuvent inclure des opérations telles que la récupération de données depuis une API, la manipulation directe du DOM, la configuration de timers, etc.
Le code à l'intérieur de useEffect
est une fonction qui sera exécutée après que le composant soit rendu.
Le tableau vide []
en second argument signifie que cet effet ne s'exécutera qu'une seule fois, après le premier rendu du composant :
tsxconst App = () => {const [actionToBePerformed, setActionToBePerformed] = useState(false);const [pizzas, setPizzas] = useState<Pizza[]>([]);useEffect(() => {// Code de la fonction}, []);// ...
Si vous aviez mis des variables d'état dans ce tableau, l'effet se serait exécuté à chaque fois que ces variables auraient changé.
Un exemple est donné dans le composant AudioPlayer
du tutoriel :
tsconst AudioPlayer = ({sound,actionToBePerformed,clearActionToBePerformed,}: AudioPlayerProps) => {const [isPlaying, setIsPlaying] = useState(false);const audioRef = useRef<HTMLAudioElement>(null);useEffect(() => {const audioElement = audioRef.current;if (audioElement && actionToBePerformed) {console.log("actionToBePerformed", actionToBePerformed);if (audioElement.paused) audioElement.play();else audioElement.pause();clearActionToBePerformed();}}, [actionToBePerformed]);
La fonction a l'intérieur de useEffect
ne sera appelée que si la valeur de actionToBePerformed
change.
En résumé, pour les fetch de données, si on souhaite le faire qu'une seule fois, au tout premier rendu du composant, alors il faut utiliser useEffect
avec un []
en deuxième paramètre.
La suite va expliquer les fondements de la programmation asynchrone en JS/TS.
Les "promises" & fetch
La méthode fetch
renvoie une Promise
, qui est un objet représentant un état intermédiaire d'une opération. Le code des callbacks s'exécute quand la tâche asynchrone est finie avec succès ou si la tâche échoue.
Les états d'une promesse sont les suivants :
- pending : état initial,
- fulfilled : l'opération asynchrone a été terminée avec succès ; par exemple la requête
fetch()
a obtenu un flux de données avec la RESTful API, - rejected : l'opération asynchrone a échouée ; par exemple la requête
fetch
est mal construite.
Pour récupérer le résultat d'une méthode asynchrone, on va faire appel :
.then( callback )
: ce morceau de code permet de chaîner des traitements asynchrones. Par exemple, à la fin du premier traitement asynchrone associé aufetch()
, on appelle
ts1fetch("http://localhost:3000/pizzas")2.then((response) => {3if (!response.ok)4throw new Error(5`fetch error : ${response.status} : ${response.statusText}`6);7return response.json();8})9.then((pizzas) => setPizzas(pizzas))10.catch((err) => {11console.error("HomePage::error: ", err);12});
La callback sera appelée et recevra comme paramètre un objet de type Response
: cet objet ne contient pas encore le contenu du body de la réponse. En fait, Response.body
est un flux de données (un stream), il faudra donc faire appel à un traitement asynchrone pour obtenir le contenu du body sous forme d'un objet JS.
C'est ce qui est fait en renvoyant return response.json();
: la fonction json()
renvoie une promesse, c'est à dire qu'une fois le traitement terminé, nous pourrons chaîner celui-ci via un autre .then()
.
C'est ainsi que nous chaînons, une fois le body
disponible, l'appel d'une deuxième callback qui s'occupe de mettre à jour la variable d'état pizza
. Cette deuxième callback recevra en paramètre le body de la réponse sous forme d'un objet JS :
ts1fetch("http://localhost:3000/pizzas")2.then((response) => {3if (!response.ok)4throw new Error(5`fetch error : ${response.status} : ${response.statusText}`6);7return response.json();8})9.then((pizzas) => setPizzas(pizzas))10.catch((err) => {11console.error("HomePage::error: ", err);12});
.catch( callback )
: ce morceau de code permet d'exécuter une callback lorsque la tâche asynchrone associée à la promesse échoue. Dans le code, on voit que l'on affiche juste un message dans la console :
js1fetch("http://localhost:3000/pizzas")2.then((response) => {3if (!response.ok)4throw new Error(5`fetch error : ${response.status} : ${response.statusText}`6);7return response.json();8})9.then((pizzas) => setPizzas(pizzas))10.catch((err) => {11console.error("HomePage::error: ", err);12});
.finally( callback )
: si l'on souhaite exécuter une callback quelque soit le résultat de la promesse, en cas de succès ou d'échec.
⚡ Pour le chaînage des traitements via plusieurs callback appelées au sein de .then()
, cela n'est possible que s'il y a un return
dans les callback.
En effet, si une callback dans la gestion de promesses retourne :
- Une valeur : la promesse retournée par
then
est résolue avec la valeur. - Pas de valeur : la promesse retournée par
then
est résolue avecundefined
. - Une autre promesse "pending": la promesse retournée par
then
est résolue/rejetée à la suite de la résolution/rejet de la promesse retournée par la callback.
Pour info, autrefois, pour la programmation asynchrone en JS, nous utilisions simplement les callbacks, des fonctions que l'on passait en argument d'autres fonctions. Le code pouvait facilement devenir illisible et donc difficilement maintenable.
Exercice 2.13 : Premier fetch online
Nous souhaitons consommer une API qui nous permette d'afficher des blagues.
Nous avons trouvé une restful API qui permet très facilement de générer de manière aléatoire des jokes
: JokeAPI.
Cette API est très simple d'utilisation. D'ailleurs, la page du site JokeAPI vous donne un exemple fonctionnel de comment récupérer des blagues simples dans l'onglet Try it out here
. Pour cela, désélectionnez twopart
et vous obtenez l'URL pour faire vos requêtes en dessous du formulaire.
Veuillez créer un nouveau projet en utilisant les technos Vite + React + TS + SWC nommé /exercises/2.13-14
dans votre repo git.
Dans votre application, veuillez afficher une joke
après l'avoir récupérée de JokeAPI
, en donnant ces 2 informations :
- la catégorie associée à la
joke
; - le texte associé à la
joke
.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.13
.
Exercice 2.13b : Un autre fetch online
Nous avons trouvé une restful API qui permet très facilement de générer de manière aléatoire des photos de chiens : Dog API.
Cette API est très simple d'utilisation. D'ailleurs, la page du site Dog API vous donne un exemple fonctionnel de comment récupérer aléatoirement une photo de chien.
Veuillez créer un nouveau projet en utilisant les technos Vite + React + TS + SWC nommé /exercises/2.13b
dans votre repo git.
Dans votre application, veuillez afficher 3 photos aléatoires de chiens. Veuillez ajouter un bouton qui permette de rafraîchir les photos avec 3 nouvelles photos aléatoires.
Comme contrainte d'implémentation, vous devez créer un composant RandomDog
et l'appeler 3 fois dans votre composant principal.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.13b
.
🤝 Tips
- Comment forcer le composant
App
à faire un rerender de ses enfants ?
Vous pouvez utiliser un état qui sera modifié à chaque fois que vous cliquez sur le bouton de rafraîchissement des photos de chiens.
Cela forcera le composantApp
à faire un rerender de ses enfants. - Pensez à faire en sorte, pour que le rerender des enfants fonctionne, qu'une nouvelle clé soit générée pour chaque enfant (
RandomDog
). Si aucune clé n'est générée, React ne fera pas de rerender des enfants, car il considère que les enfants n'ont pas changé ; )
Gestion d'événements associés au temps
Gestion d'un timer
setTimeout(f,t)
permet l'exécution d'une callback f
à l'expiration d'un timer, après t
ms.
clearTimeout()
permet de stopper l'exécution d'une callback qui a été appelée via setTimeout()
.
Cet exemple montre comment mettre en place un minuteur qui met à jour un état après 3 secondes :
tsx1import { useState, useEffect } from 'react';23const TimerComponent = () => {4const [message, setMessage] = useState('Attendez 3 secondes...');56useEffect(() => {7// Définir un timer de 3 secondes8const timer = setTimeout(() => {9setMessage('3 secondes se sont écoulées!');10}, 3000);1112// Nettoyage du timer pour éviter des fuites de mémoire si le composant est démonté avant que le timer se déclenche13return () => clearTimeout(timer);14}, []); // Le tableau vide [] signifie que cet effet s'exécute une seule fois lors du montage1516return (17<div>18<p>{message}</p>19</div>20);21};2223export default TimerComponent;
Ici nous apprenons un nouveau concept associé à useEffect
: la fonction de nettoyage. Une fonction de nettoyage, au sein de useEffect
, est une fonction qui sera appelée lors de la destruction d'un composant (par exemple lorsque l'on passe d'une page à une autre, la page sera "détruite"). La fonction de nettoyage est spécifiée dans le return
de useEffect
.
Ci-dessus, la fonction de nettoyage clearTimeout(timer)
est retournée par useEffect
pour s'assurer que le timer est nettoyé si le composant est démonté avant que le timer ne se déclenche. Cela sera très utile à mettre en place lorsqu'une action est associée à une page uniquement.
Gestion d'intervalles de temps & actions répétées
setInterval(f,t)
permet l'exécution d'une callback f
tous les t
ms.
clearInterval()
permet de stopper les appels à la callback qui ont été programmés via setInterval()
.
Cet exemple montre comment mettre en place un intervalle qui met à jour un compteur toutes les secondes. :
tsx1import { useState, useEffect } from 'react';23const CounterComponent = () => {4const [count, setCount] = useState(0);56useEffect(() => {7// Définir un intervalle qui incrémente le compteur toutes les secondes8const interval = setInterval(() => {9setCount(prevCount => prevCount + 1);10}, 1000);1112// Nettoyage de l'intervalle pour éviter des fuites de mémoire si le composant est démonté13return () => clearInterval(interval);14}, []); // Le tableau vide [] signifie que cet effet s'exécute une seule fois lors du montage1516return (17<div>18<p>Compteur: {count}</p>19</div>20);21};2223export default CounterComponent;
Exercice 2.14 : Gestion d'événement temporel
Veuillez continuer l'exercice précédent nommé /exercises/2.13-14
afin d'afficher une nouvelle blague toute les 10 secondes.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.14
.
async / await
Introduction
Plutôt que d'utiliser des .then()
pour chaîner des traitements asynchrones, il est possible de simplifier la syntaxe des promesses à l'aide de async
et await
.
On va donc écrire du code d'une manière équivalente à ce qui serait fait en programmation synchrone, tout en bénéficiant des effets de la programmation asynchrone.
async / await : les bases
Pour ce nouveau tutoriel, nous allons refactorer l'IHM pour améliorer le code associé aux appels asynchrones aux API.
Pour ce tutoriel, veuillez créer une copie du tutoriel fetch-no-proxy
, si nécessaire voici le code du tutoriel fetch-no-proxy, et l'appeler async-await
. Changez le nom du projet dans package.json
.
Nous allons donc refactorer le code où est fait le fetch
, c'est à dire App
:
tsx1const App = () => {2const [actionToBePerformed, setActionToBePerformed] = useState(false);3const [pizzas, setPizzas] = useState<Pizza[]>([]);45useEffect(() => {6fetchPizzas();7}, []);89const fetchPizzas = async () => {10try {11const response = await fetch("http://localhost:3000/pizzas");1213if (!response.ok)14throw new Error(15`fetch error : ${response.status} : ${response.statusText}`16);1718const pizzas = await response.json();19setPizzas(pizzas);20} catch (err) {21console.error("HomePage::error: ", err);22}23};
Pour tester ce code, il ne faut pas oublier de démarrer la RESTful API auparavant, la même qu'au tutoriel précédent (téléchargez, et désarchivez cette API : RESTful API offerte grâce à json-server & exécutez la).
Voici quelques caractéristiques importantes de async
/ await
:
await
est utilisé pour chaîner une tâche asynchrone (sur une fonction renvoyant une promesse) et ne peut se faire qu'au sein d'une fonction taguée parasync
; c'est donc le remplaçant du.then(callback)
.
⚡ Attention, il est donc important qu'au niveau de la fonctionarrow
, à la ligne 1 du code donné ci-dessus, on indique leasync
!
⚡ Dans le code donné ci-dessus, il est aussi très important de ne pas oublier lesawait
. N'hésitez pas à faire le test en enlevant leawait
deconst pizzas = await response.json();
.- Toute fonction "taguée" par
async
renvoie automatiquement une promesse ; cela signifie dans le code ci-dessus que la fonctionfetchPizzas
est elle même asynchrone. - On utilise des blocs
try
/catch
pour gérer les erreur ; c'est donc le remplaçant du.catch(callback)
.
💭 Il est à parier, et n'hésitez pas à trouver un moyen de vous en rendre compte visuellement, que le footer s'affiche avant le menu !
💭 Pourquoi ne pas avoir mis directement un await dans le useEffect
, sans créer la fonction fetchPizza
? On aurait pu tenter quelque chose du genre :
tsuseEffect(async () => {try {const response = await fetch("http://localhost:3000/pizzas");if (!response.ok)throw new Error(`fetch error : ${response.status} : ${response.statusText}`);const pizzas = await response.json();setPizzas(pizzas);} catch (err) {console.error("HomePage::error: ", err);}}, []);
Cela n'est pas possible car useEffect
, via TS et le linter, ne permet pas d'avoir une fonction asynchrone en paramètre ! Ainsi, si l'on souhaite lancer une action asynchrone, nous devons faire preuve d'ingénuité : il faut créer une fonction, et l'appeler au sein de la callback de useEffect
; )
Exercice 2.14b : async / await périodique
Veuillez créer un nouveau projet nommé /exercises/2.14b
dans votre repo git, sur base d'un copier / coller de /exercises/2.13b
.
Nous allons faire un refactor de l'application permettant d'afficher 3 photos aléatoires de chiens.
Dans un premier temps, veuillez faire un refactor du fetch
en utilisant async
/ await
.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.14b-refactor
.
Ensuite, veuillez rafraîchir automatiquement les photos de chiens toutes les 5 secondes. Vous devez retirer le bouton de rafraîchissement des photos de chiens.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.14b
.
Challenge
Il vous reste du temps ? Vous êtes extrêmement motivé ?
Veuillez faire en sorte que lorsque les utilisateurs passent leur souris sur une photo de chien, la photo reste affichée (il n'y a pas de fetch de nouvelles photos pour ce RandomDog
). Lorsque la souris quitte la photo, le comportement initial est rétabli, on recommence à afficher des photos de chiens aléatoires toutes les 5 secondes.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.14b+
.
Opération asynchrone d'écriture d'une ressource
A présent, nous souhaiterions que notre IHM puisse créer une ressource au sein de la RESTful API. Dans un premier temps, nous allons mettre à jour le frontend en acceptant que n'importe quel utilisateur puisse créer une pizza et l'ajouter au menu de la pizzeria.
Bien entendu, cela est temporaire. Nous verrons plus tard comment sécuriser cette opération, en autorisant un admin seulement à réaliser l'ajout d'une pizza au menu.
Nous allons maintenant ajouter l'interaction avec l'API au sein de AddPizzaPage
.
Lorsque nous soumettons le formulaire, nous voulons faire une requête de création de pizza à la RESTful API, c'est donc une requête de type POST /pizzas
qui doit être l'équivalent de ce que nous faisions avec REST Client. Pour rappel, nous faisions une requête de ce genre :
http### Create a pizzaPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}
Ici, c'est le JS/TS à rajouter dans la fonction addPizza
de App
qui doit, permettre de récupérer les données de la pizza à créer et faire un fetch
de l'opération de création offerte par l'API.
Pour arriver à nos fins, veuillez mettre à jour la fonction addPizza
dans Main
:
tsx1const addPizza = async (newPizza: NewPizza) => {2try {3const options = {4method: "POST",5body: JSON.stringify(newPizza),6headers: {7"Content-Type": "application/json",8},9};1011const response = await fetch("http://localhost:3000/pizzas", options); // fetch retourne une "promise" => on attend la réponse1213if (!response.ok)14throw new Error(15`fetch error : ${response.status} : ${response.statusText}`16);1718const createdPizza = await response.json(); // json() retourne une "promise" => on attend les données1920setPizzas([...pizzas, createdPizza]);21} catch (err) {22console.error("AddPizzaPage::error: ", err);23}24};25}2627export default AddPizzaPage;
Pour la nouveauté et le fetch
:
- Pour faire une requête de type
POST
, tout comme pour les requêtes de typeDELETE
,PATCH
,UPDATE
..., il faut l'indiquer à la méthodefetch
.
Cela est indiqué dans un objet que nous appelons généralementoptions
qui doit contenir la propriétémethod
. - Lorsque l'on doit envoyer des données dans le
body
d'une requête, alors il faut le faire au sein de la propriétébody
. Ici, nous souhaitons envoyer un objet contenant les propriétéstitle
etcontent
au format JSON. Nous devons donc utiliser la méthodeJSON.stringify
qui permet de créer une représentation JSON d'un objet JS/TS. - Il est très important de spécifier le type de la représentation de l'objet qui devrait être utilisé par l'API et qui se trouve dans le body de la requête. Cela est fait via un
header
et la propriétéContent-Type
('Content-Type': 'application/json',
).
⚡ Si vous oubliez cela, l'API ne pourra pas parser les données au format JSON vers des objets JS/TS et donc les opérations d'écriture de ressources échoueront !
Veuillez vérifier que tout fonctionne correctement ; )
💭 Comment vérifier que les données persistent bien dans notre API après avoir soumis une nouvelle pizza ?
Faites un refresh de votre page... Vous pouvez même stopper votre frontend et le redémarrer (mais pas votre API). La nouvelle pizza devrait toujours être affichée. Pour rappel, quand les données étaient traitée dans un tableau en mémoire vive via notre frontend, lors d'un refresh, on perdait ces données.
Quelques mots sur le type en TypeScript
N'avez-vous pas été surpris que lorsque nous avons mis à jour addPizza
, en la rendant asynchrone à l'aide du mot clé async
, nous n'ayons pas du changer le type de addPizza
au sein du type PizzeriaContext
?
Pour garder notre typage propre, nous vous recommandons de mettre à jour le retour de addPizza
dans /src/types.ts
:
tsinterface PizzeriaContext {pizzas: Pizza[];setPizzas: (pizzas: Pizza[]) => void;actionToBePerformed: boolean;setActionToBePerformed: (actionToBePerformed: boolean) => void;clearActionToBePerformed: () => void;drinks: Drink[];addPizza: (newPizza: NewPizza) => Promise<void>;}
C'est une Promise
qui est retournée par la fonction addPizza
.
Création de fonctions asynchrones renvoyant une promesse
A l'aide d'async
/ await
, il est très simple de créer des fonctions asynchrones qui renvoient une promesse.
Nous l'avons déjà fait dans ce tutoriel. Imaginons que nous souhaitons créer une fonction asynchrone qui renvoie toutes les pizzas qui sont offertes par l'opération de lecture des pizzas de la RESTful API.
Voici comment nous écririons ce code :
tsasync function getAllPizzas() {try {const response = await fetch("http://localhost:3000/pizzas");if (!response.ok)throw new Error(`fetch error : ${response.status} : ${response.statusText}`);const pizzas = await response.json();return pizzas;} catch (err) {console.error("getAllPizzas::error: ", err);throw err;}}
Cette fonction getAllPizzas
ne renvoie pas un array de pizzas, mais une Promise
!
Si la promesse :
- résout avec succès, alors c'est bien un array de pizzas qui sera renvoyé par cette fonction.
- échoue, c'est une exception qui sera renvoyée.
Pour que cela fonctionne, vous devez donc faire en sorte, dans vos fonctions asynchrones, de faire unthrow
d'une erreur en cas d'échec du traitement asynchrone.
Comment utiliser ce code au sein de App
?
Voici comment le code pourrait être mis à jour pour utiliser la fonction asynchrone getAllPizzas
au sein de App
:
tsxconst App = () => {const [actionToBePerformed, setActionToBePerformed] = useState(false);const [pizzas, setPizzas] = useState<Pizza[]>([]);useEffect(() => {fetchPizzas();}, []);const fetchPizzas = async () => {try {const pizzas = await getAllPizzas();setPizzas(pizzas);} catch (err) {console.error("HomePage::error: ", err);}};
Si nécessaire, vous pouvez trouver le code associé à ce tutoriel ici : async-await.
Single Origin Policy & CORS
La Single Origin Policy (SOP) sont des règles appliquées par le browser afin :
- de restreindre les interactions entre un document ou script chargé par une origine avec une ressource d'une autre origine ;
- d'isoler des documents ou scripts malicieux, afin de réduire le risque des attaques.
Deux URL ont une même origine si ces caractéristiques sont les mêmes :
- protocole ;
- port ;
- host ; l'URL pointe vers le même appareil connecté à internet ou à un réseau local.
Il est possible de relaxer la sécurité via des Cross Origin Resource Sharing (CORS).
CORS est un mécanisme qui utilise des headers HTTP pour indiquer aux browsers qu'ils peuvent autoriser les accès à des ressources d'origines différentes.
Cela signifie qu'une application web qui utilise une API ne peut le faire que si les ressources demandées à l'API proviennent d'une même origine, à moins que la réponse de l'API inclut les bonnes CORS (via des header HTTP).
Si l'on autorise trop d'origines, voici un exemple classique d'attaque :

Imaginez qu'un site d'une banque ne soit pas sécurisé avec des techniques modernes. Ce site utiliserait une IHM (https://my-bank.com
), qui, via un formulaire, permettrait de faire un versement, sous réserve d'envoyer un cookie qui contiendrait une variable de session (simple mécanisme de sécurité) lors d'une requête à l'API de la banque.
Maintenant, prenons le cas d'un utilisateur qui adore jouer sur le web, un gamer en puissance. Il joue à un jeu de penguins, mais soudainement, il est redirigé vers un site malicieux (https://malicious.com
), qui lui offre un nouveau jeu avec des dinosaures. Ce site malicieux, en arrière plan, pourrait faire une requête vers la même API utilisée par https://my-bank.com
.
Comme c'est le même browser utilisé par notre gamer, tant pour faire ses virements, que pour jouer, toute requête faite vers https://api.my-bank.com
enverra d'office les cookies existants et associés au domaine api.my-bank.com
.
Les résultats peuvent être dramatiques : l'attaquant à la capacité de faire des versements jusqu'à vider le compte de notre pauvre gamer.
Bien sûr, grâce à la SOP appliquée par nos browser, par défaut, dès que le site malicieux communique avec l'API de la banque, celui-ci bloque l'accès aux ressources de l'API car l'origine du site malicieux est différente de l'origine de l'API.
Les CORS permettent de relâcher la sécurité, afin notamment, dans le scénario évoqué, d'autoriser l'origine https://my-bank.com
à accéder à l'origine https://api.my-bank.com
. En effet, c'est ce que le site de la banque souhaite.
Par contre, si la banque possède des développeurs nuls au niveau sécurité et que ceux-ci autorisent toutes les origines à interroger l'API, là, nous pourrions arriver au hacking décrit ci-dessus.
Dans ce cours, nous allons voir comment nous pouvons communiquer entre un frontend et une API fonctionnant sous deux origines différentes, à l'aide de deux techniques différentes :
proxy
: le frontend communique avec un serveur qui lui est associé, et ce serveur communique avec l'API. Le serveur 'proxy' est donc un intermédiaire entre le frontend et l'API. Nous allons voir cela dans la suite.CORS
: l'API autorise explicitement le frontend à communiquer avec elle. Cela sera vu plus tard, de manière optionnelle, dans la partie 3 de ce cours.
Simulation d'une même origine via un proxy
Il est possible de mettre en place un proxy au niveau du frontend afin de faire croire au browser que l'API et le frontend ont la même origine.
Veuillez démarrer l'API qui n'autorise aucune autre origine : services).
Nous allons mettre en place un mécanisme au niveau du frontend pour faire passer toutes les requêtes à destination de l'API par un proxy ; le proxy aura la même origine que le serveur de fichiers statiques ayant offert le frontend.
Pour ce tutoriel, veuillez créer une copie du tutoriel async-await
, si nécessaire voici le code du tutoriel async-await, et l'appeler fetch-proxy
. Changez le nom du projet dans package.json
.
Le menu des pizzas ne s'affiche pas et nous avons le problème déjà rencontré (...has been blocked by CORS policy
).
Voici le workflow que nous allons appliquer à notre site gérant la pizzeria :
- Lors du premier appel de notre browser au serveur de développement de Vite : on récupère notre SPA, c'est à dire
index.html
& tous les assets associés. - A chaque fetch, une requête est faite au proxy sur la même origine que le serveur de développement qui a offert les fichiers associés à la SPA.
- Le proxy s'occupe de transférer la requête HTTP à l'API, puis de renvoyer la réponse au browser.
- Ainsi, pour le browser, il n'y a qu'une seule origine : )
Le serveur de développement de Vite
met à disposition un proxy. Pour utiliser ce proxy, vous devez configurer Vite
. Veuillez mettre à jour le fichier vite.config.ts
:
ts1import { defineConfig } from "vite";2import react from "@vitejs/plugin-react-swc";3import checker from "vite-plugin-checker";45export default defineConfig({6plugins: [7react(),8checker({9typescript: true,10}),11],12server: {13proxy: {14"/api": {15target: "http://localhost:3000",16changeOrigin: true,17rewrite: (path) => path.replace(/^\/api/, ""),18},19},20},21});
Cela signifie qu'à chaque fois qu'une requête sera faite sur /api
(on reste sur la même origine que le serveur de fichiers statiques : 5173
est le port par défaut d'une application Vite
), celle-ci sera redirigée vers le port 3000
, le port de l'API.
Le rewrite
permet de ne pas reprendre /api
dans l'URL de la redirection :
GET /api/pizzas
devient GET http://localhost:3000/pizzas
.
Pour rappel, pour ajouter les problèmes de linter dans le browser ainsi que dans le terminal, après la transpilation/compilation, vous pouvez utiliser le plugin vite-plugin-checker
(lignes 8 à 10 ci-dessus, et ligne 3 pour l'import).
Pour cela, il ne faut pas oublier d'installer le plugin :
bashnpm i vite-plugin-checker -D
Il nous reste à mettre à jour les fetch
au sein de App
:
ts1async function getAllPizzas() {2try {3const response = await fetch("/api/pizzas");45if (!response.ok)6throw new Error(7`fetch error : ${response.status} : ${response.statusText}`8);910const pizzas = await response.json();1112return pizzas;13} catch (err) {14console.error("getAllPizzas::error: ", err);15throw err;16}17}1819const addPizza = async (newPizza: NewPizza) => {20try {21const options = {22method: "POST",23body: JSON.stringify(newPizza),24headers: {25"Content-Type": "application/json",26},27};2829const response = await fetch("/api/pizzas", options); // fetch retourne une "promise" => on attend la réponse3031if (!response.ok)32throw new Error(33`fetch error : ${response.status} : ${response.statusText}`34);3536const createdPizza = await response.json(); // json() retourne une "promise" => on attend les données3738setPizzas([...pizzas, createdPizza]);39} catch (err) {40console.error("AddPizzaPage::error: ", err);41}42};
A ce stade-ci, tout devrait fonctionner : le menu des pizzas est affiché suite à l'appel à notre RESTful API ne relaxant pas la sécurité !
🍬 Voici quelques infos non capitales pour ce cours-ci :
- Il existe une multitude de proxy pour un environnement de développement :
Vite development server
et son proxy,VS Code proxy
, proxy léger deNode
directement configurable viapackage.json
("proxy": "http://localhost:3000",
), ... - Il existe par exemple un proxy complet sous Node :
http-proxy-middleware
. - Pour la production, lorsque vous déployez une application web sur le cloud, il faudra trouver les instructions de votre provider pour voir comment configurer le proxy.
Par exemple, pour configurer un static file server et son proxy sous heroku (provider de services d'hébergements sur le cloud), il faut configurer le fichier/static.json
.
Si nécessaire, vous pouvez trouver le code associé à ce tutoriel ici : fetch-proxy.
Exercice 2.15 : Proxy & async / await
Veuillez partir d'une copie de l'exercice (/exercises/2.10-11-12
) pour créer un nouveau projet nommé exercises/2.15
afin de compléter l'application myMovies
.
Nous souhaitons maintenant que toutes les données de films soient fournies par une API que vous avez développées lors des premières semaines de cours. Cette API se trouve normalement dans votre repo git, dans le dossier /exercises/1.8
. Pensez à démarrer cette API.
Pour la page présentant les cinémas, vous pouvez continuer à utiliser les données en dur. Pour toutes les autres pages, vous devez utiliser l'API de films.
NB : Si vous n'avez pas réalisé l'exercice de création d'une API de films, vous pouvez utiliser l'API de films fournie ici : https://github.com/e-vinci/ts-exercises/tree/main/ex1.8
A l'aide d'un proxy, et de async / await, veuillez consommer votre API de films afin :
- de lire tous les films offerts par votre API et les afficher dans votre frontend ;
- de créer des films. Une fois un film créé, vous devez faire un appel à votre API pour récupérer tous les films et les afficher dans votre frontend.
💭 Une fois un film ajouté, pourquoi faire un appel à l'API afin d'obtenir tous les films et les afficher ? Pourquoi ne pas se satisfaire de simplement considérer tous les films comme étant le résultat du fetch précédent l'ajout en y ajoutant le film renvoyé par l'API lors de l'opération d'ajout ?
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.15
.
Exercice 2.15b : async / await : effacer une ressource
Veuillez partir d'une copie de l'exercice (/exercises/2.15
) pour créer un nouveau projet nommé exercises/2.15b
afin de compléter l'application myMovies
.
Dans la page affichant les films favoris, nous souhaitons offrir la possibilité d'effacer un film. Pour ce faire, veuillez ajouter un bouton pour chaque film favori, qui, lorsqu'il est cliqué, efface le film de la liste des films favoris.
Attention, pour effacer un film, il faut faire une requête de type DELETE
à l'API ; )
De même, lorsque dans la HomePage l'utilisateur a sélectionné un film, la page affichant ce film doit afficher un bouton pour effacer le film.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.15b
.
🍬 Challenge
Pensez à ajouter une icône de poubelle pour chaque film favori, afin de rendre l'interface plus intuitive (et plus jolie) ; )
Ainsi, vous pouvez retirer le bouton Delete
et le remplacer par une icône de poubelle.
Tips
- N'hésitez pas à utiliser ce site qui offre des icônes sans qu'aucune installation ne soit nécessaire : https://heroicons.com/
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.15b+
.