# Développer un projet fullstack en JavaScript
# Outils
Pour développer en fullstack, nous utiliserons un grand nombre d'outils.
- L'éditeur que je recommande est Visual Studio Code (opens new window) (attention, rien à voir avec Visual Studio) cf. Section dédiée et un certains nombre de ses extensions
- Git (opens new window) pour versionner notre code cf. Section dédiée
- Firefox Developer Editiion (opens new window) pour tester notre site
- eslint (opens new window) pour linter (opens new window) notre code
- nodemon (opens new window) pour redémarrer notre serveur web qui sera notre API RESTfull à chaque modification de nos fichiers (mode development uniquement)
Le projet sera composé de 2 parties : la partie Front ou Client qui sera faite avec Vue.js
et les bibliothèques de l'écosystème Vue et qu'on mettra dans un dossier client
(libre à vous de l'appeler autrement, par exemple front
ou bien frontend
, mais n'oubliez pas de remplacer client
de cette documentation par le nom que vous aurez choisi), et une partie Back qui sera faite avec expressjs qu'on mettra dans un dossier server
(que vous pourrez appeler back
ou backend
ou autre, avec les mêmes avertissements que ci-dessus pour la partie client).
# Bibliothèques et frameworks
Les bibliothèques que nous utiliserons sont :
# Pour le front
- Vue.js (opens new window), Vue-router (opens new window), Vuex (opens new window) pour la partie JavaScript pour les composants, le routage, et la gestion de l'état de l'application
- Tailwindcss (opens new window) pour la partie CSS (si vous préférez une autre bibliothèque, libre à vous de l'utiliser)
# Pour le back
- expressjs (opens new window) pour la gestion des requêtes HTTP et des routes
- Mongoose (opens new window) pour la gestion des données dans la base MongoDB
# Préalable
- Créer un répertoire pour le projet fullstack (celui qui contiendra le dossier
client
et le dossierserver
) - ouvrir ce projet avec VS Code
- ouvrir le terminal intégré de VS Code
- initialiser un projet git avec la commande
git init
- créer un fichier
.gitignore
(doit commencer par un point.
) avec le contenu créé sur gitignore.io (opens new window) avec les optionsNode
,Code
,Linux
,Windows
, etmacOS
- créer un fichier .editorconfig ce contenu :
root = true
[*]
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
end_of_line = lf
[*.{js,json,yml,html}]
indent_style = space
indent_size = 2
[*.js]
quote_type = single
- ajouter ces 2 fichiers à l'index avec la commande
git add .gitignore .editorconfig
- créer un commit avec la commande
git commit -m ":tada: Init fullstack project"
# La partie front ou client
- Installer ou mettre à jour la dernière version de @vue/cli avec la commande
npm i -g @vue/cli
(éventuellement précédée desudo
si vous êtes sur MacOS et que vous n'utilisez pasnvm
(opens new window)) - Créer une application vue avec Vue3, eslint, babel, PWA, router et store avec la commande
vue create client
(cette commande va créer le répertoireclient
)
Sélectionner les fonctionnalités suivantes :
Babel
(opens new window) (pour la transpilation),PWA
(opens new window) (pour le mode offline),Router
(opens new window) (pour gérer le routage côté client),Vuex
(opens new window) (pour le store, qui gérera l'état de l'application),Linter
(opens new window) avec eslint (opens new window) (pour respecter les bonnes pratiques de syntaxe, d'indentation, etc.)
Choisir Vue 3 (parce qu'on est moderne) :
Choisir History mode (opens new window) :
Je recommande la config Standard (opens new window) pour Eslint :
Sélectionner de formatter le code au moment du commit (git (opens new window) hooks (opens new window), on verra ça plus tard) :
Sans grande importance, je recommande des fichiers séparés pour les différentes config, plutôt que de tout mettre :
Inutile de sauvegarder cette sélection (laisser par défaut - N
) :
# Les configurations et extensions de VS Code pour développer une application en Vue
# Configuration générale :
Rajouter les lignes suivantes dans vos paramètres généraux (le fichier settings.json
) pour que Emmet fonctionne avec les fichiers .vue
(SFC) et les styles en postcss :
"emmet.includeLanguages": {
"vue-html": "html",
"postcss": "css"
},
"emmet.syntaxProfiles": {"postcss": "css"},
"files.associations": {
"*.css": "postcss",
"*.html": "html"
},
# Extensions généralistes (HTML / JS / CSS)
- Auto Close Tag (opens new window)
- Auto Rename Tag (opens new window)
- npm (opens new window)
- npm Intellisense (opens new window)
- Path Intellisense (opens new window)
- Bracket Pair Colorizer 2 (opens new window)
- indent-rainbow (opens new window)
- indenticator (opens new window)
- Git Graph (opens new window)
- GitLens (opens new window)
- Live Share (opens new window)
- JavaScript (ES6) code snippets (opens new window)
- Auto Import (fork) (opens new window)
- Babel JavaScript (opens new window)
Et les extensions suivantes avec des configurations supplémentaires :
-
- avec ces paramètres à rajouter :
"editor.codeActionsOnSave": { "source.fixAll": true, }, "eslint.alwaysShowStatus": true, "eslint.enable": true, "eslint.workingDirectories": [{ "mode": "auto"}], "eslint.validate": [ "vue", "html", "javascript", "javascriptreact", // Facultatif, ne sert que pour le développement de composants React "svelte", // Facultatif, ne sert que pour le développement de composants Svelte ],
-
- avec ces paramètres :
"css.validate": true, "less.validate": true, "scss.validate": true,
# Extensions dédiées à Vue
- Vetur (opens new window) indispensable pour développer en Vue.js
- Vue VSCode Snippets (opens new window) pour avoir des raccourcis (
vbase
, par exemple) pour écrire plus facilement du code de composants Vue.
# L'architecture des dossiers après la création de l'appli par @vue/cli
Après la commande vue create client
, voici à quoi doit ressembler l'arborescence du dossier client
:
client
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── img
│ │ └── icons
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── android-chrome-maskable-192x192.png
│ │ ├── android-chrome-maskable-512x512.png
│ │ ├── apple-touch-icon-120x120.png
│ │ ├── apple-touch-icon-152x152.png
│ │ ├── apple-touch-icon-180x180.png
│ │ ├── apple-touch-icon-60x60.png
│ │ ├── apple-touch-icon-76x76.png
│ │ ├── apple-touch-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── msapplication-icon-144x144.png
│ │ ├── mstile-150x150.png
│ │ └── safari-pinned-tab.svg
│ ├── index.html
│ └── robots.txt
└── src
├── App.vue
├── assets
│ └── logo.png
├── components
│ └── HelloWorld.vue
├── main.js
├── registerServiceWorker.js
├── router
│ └── index.js
├── store
│ └── index.js
└── views
└── Home.vue
# Modification des règles eslint
Je conseille de modifier les règles eslint de la sorte (les lignes commençant avec un signe moins -
sont à effacer, les lignes commençant avec un signe plus +
sont à ajouter) :
module.exports = {
root: true,
env: {
- node: true
+ node: true,
},
extends: [
'plugin:vue/vue3-essential',
- '@vue/standard'
+ 'plugin:vue/vue3-recommended', // [1]
+ '@vue/standard',
],
parserOptions: {
- parser: 'babel-eslint'
+ parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
- 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
- }
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+ 'comma-dangle': [2, 'always-multiline'], // [2]
+ },
}
Seule les lignes avec les notes [1]
et [2]
sont importantes. Le reste des modifications est une conséquence de la ligne avec l'annotation [2]
: ajouter des virgules ,
à toutes les fins de lignes dans les littéraux objets et tableaux, pour que les modifications futures soient plus claires. Cf. la doc eslint (opens new window) pour plus de détails. C'est la seule règle que je modifie par rapport aux conventions de standardjs (opens new window).
La ligne avec l'annotation [1]
sert à nous assurer de créer des composants Vue avec les bonnes pratiques (opens new window).
# Nettoyage de l'application par défaut
Nous n'avons pas besoin de certains fichiers ni de certaines portions de code créés par la commande vue create client
.
Apporter les modifications suivantes au fichier App.vue
(les lignes commençant avec un signe moins -
sont à effacer, les lignes commençant avec un signe plus +
sont à ajouter) :
<template>
<div class="home">
- <img alt="Vue logo" src="../assets/logo.png">
- <HelloWorld msg="Welcome to Your Vue.js App"/>
+ <h1>Home</h1>
</div>
</template>
<script>
-// @ is an alias to /src
-import HelloWorld from '@/components/HelloWorld.vue'
-
export default {
name: 'Home',
- components: {
- HelloWorld
- }
}
</script>
Comme nous n'avons plus besoin du composant HelloWorld.vue
, on peut l'effacer. Il se trouve dans le dossier client/src/components
.
Nous pouvons désormais lancer la commande npm run serve
pour commencer à développer notre application. Voilà ce qui devrait s'afficher dans le navigateur si on visite l'adresse http://localhost:8080
:
# Les différentes propriétés d'un composant
Dans un composant Vue, des propriétés de la partie script sont utilisables dans la partie template, notamment ce qui se trouve dans les propriétés suivantes d'un composant :
# data
data
est une fonction et qui renvoie un objet dont chaque nom de propriété sera utilisable dans la <template>
. Chaque propriété peut contenir n'importe quel type de valeur JavaScript, cependant, il est rare que ce soit une fonction, les fonctions devraient plutôt être dans methods
(cf. plus loin).
Exemple :
<template>
<div>
<p>
Hello, {{ firstname }} {{ lastname }}
</p>
</div>
</template>
<script>
export default {
name: 'Profile'
data () {
return {
firstname: 'Erin',
lastname: 'Brockovich'
}
},
}
</script>
# computed
computed
est un objet dont chaque propriété sera une fonction, mais dont le nom sera utilisé comme une propriété dans la <template>
. Chaque propriété de computed
est une propriété calculée soit à partir d'une propriété de data
, soit de props
, soit d'une autre propriété computed
, soit d'une combinaison des 3. Il faudra dans la partie <script>
, contrairement à dans la <template>
, utiliser this.
devant les data
, computed
, methods
et props
.
Exemple :
<template>
<div>
<p>
Hello, {{ fullname }}
</p>
</div>
</template>
<script>
export default {
name: 'Profile'
data () {
return {
firstname: 'Erin',
lastname: 'Brockovich',
},
},
computed {
fullname () {
return this.firstname + ' ' + this.lastname
},
},
}
</script>
# props
La propriété props
contiendra des propriétés dont les valeurs seront des objets ou des String (mais je déconseille, sauf pour les Boolean
) qui définiront ce qui peut être passé à ce composant par un composant parent.
Exemple :
Le composant parent :
<template>
<div>
<app-hello firstname="Rosa" lastname="Parks" />
</div>
</template>
Le composant enfant :
<template>
<div>
{{ fullname }}
</div>
</template>
<script>
export default {
name: 'ChildComponent',
props: {
firstname: String, // Non recommandé
lastname: { // Recommandé : permet de mettre une valeur par défaut
type: String,
default: '',
},
},
computed: {
fullname () {
return this.firstname + ' ' + this.lastname
},
},
}
</script>
# methods
methods
contiendra des propriétés qui seront des fonctions, qui auront accès à this
(qui représentera le composant).
Exemple :
<template>
<div>
<button @click="decrement">
-
</button>
{{ counter }}
<button @click="increment">
+
</button>
</div>
</template>
<script>
export default {
name: 'BasicCounter'
data () {
return {
counter: 0
}
},
methods: {
increment () {
this.counter++
},
decrement () {
this.counter--
},
},
}
</script>
# v-on:
ou @
Dans le code ci-dessus, @
est le raccourci de v-on:
et correspond à un écouteur d'événement. Cet événement peut être un événement DOM natif (comme un click
oun input
, mouseover
, etc.) ou bien un événement personnalisé émis avec la méthode this.$emit('nom-d-evenement', optionalPayload)
par le composant enfant. Cette méthode $emit
est disponible dans n'importe quel composant Vue.
Composant enfant :
<template>
<div>
<button @click="decrement">
-
</button>
{{ counter }}
<button @click="increment">
+
</button>
</div>
</template>
<script>
export default {
name: 'BasicCounter'
props: {
counter: {
type: Number,
default: 0,
},
},
methods: {
increment () {
this.$emit('increment')
},
decrement () {
this.$emit('decrement')
},
},
}
</script>
Utilisation de l'événement personnalisé dans un composant parent :
<template>
<div>
<basic-counter
:counter="counter"
@increment="onIncrement"
@decrement="onDecrement"
/>
</div>
</template>
<script>
export default {
name: 'App',
data () {
return {
counter: 0
}
},
methods: {
onDecrement () {
this.counter--
},
onIncrement () {
this.counter++
},
},
}
</script>
# Le :
devant les props dans la template
Sur le composant précédent, vous aurez remarqué le caractère :
juste avant la props counter
dans la template. Il est là pour signifier que ce n'est pas la chaîne de caractères "counter"
qui sera passée en tant que props au composant BasicCounter
, mais l'évaluation de counter
, c'est-à-dire le Number
contenu dans la propriété counter
du composant (qui sera soit dans l'objet retourner par data()
(c'est le cas ici), ou bien une props
, ou bien encore une propriété de computed
.
# Dernières notes sur les SFC
Il est fortement recommandé de nommer les composants avec 2 mots (BasicCounter
plutôt que Counter
, par exemple), la seule exception étant App
. Plus détails (opens new window) sur la [documentation officielle](https://vuejs.org/v2/style-guide/.
Il vaut mieux éviter au maximum la logique dans les composants (faire des composants "idiots" -dumb components), et limiter le couplage avec d'autres composants ou avec le router (cf. plus loin). Il vaut mieux déporter notamment la logique métier (avec des algorithmes correspondants à des règles métiers, par exemple vérification des droits d'un utilisateur pour telle ou telle fonctionnalité) dans des fichiers qui seraient dans un dossier business
, et pour les algos plus génériques (conversion du système métrique au système impérial, par exemple), les mettre dans des fichiers qui seraient dans un dossier utils
, par exemple.
Si une donnée dépend d'une autre, et est valable pour tout le composant, privéligier l'utilisation de computed
plutôt qu'une méthode.
Par défaut, Vue CLI installe les règles vue/essential
en plus de l'ensemble qu'on a choisi lors de la création de l'application (AirBnb, Prettier ou Standard). Il est fortement recommandé d'ajouter vue-recommended
. Il faut l'ajouter dans le fichier .eslintrc.js
:
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/essential',
+ 'plugin:vue/recommended',
'@vue/standard'
],
parserOptions: {
parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
}
# Le routage côté client
Le routage côté client se fait avec une librairie qui permet d'afficher le contenu de l'application selon l'URL. La librairie officielle pour les applications en Vue.js
est vue-router
(opens new window). C'est celle que nous allons utiliser (il n'y a d'ailleurs pas de raison d'en utiliser une autre).
# La librairie vue-router
# Utilisation
La librairie a été installée lors de la création du projet avec @vue/cli. Et elle est déjà utilisée dans l'application : c'est elle qui nous permet de passer de la vue Home
à la vue About
sans recharger la page.
Le composant router-link
utilisé dans App.vue
permet de passer de la vue Home
à la vue About
sans recharger toute la page (ce que ferait une simple balise <a href="/about">About</a>
).
# Enregistrement global
Pour pouvoir utiliser un composant dans un autre composant, il faut l'importer (comme dans le code que nous avons effacé dans le composant Home.vue
qui importait le composant HelloWorld.vue
).
Pour pouvoir utiliser les composants router-link
et router-view
dans nos composants, il faut ajouter la librairie vue-router
en tant que plugin Vue
. Ceci est fait pour nous par @vue/cli
au moment de la création de l'application par défaut. Regardons le fichier main.js
.
import { createApp } from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
createApp(App).use(store).use(router).mount('#app')
Nous pouvons le réécrire de cette façon :
import { createApp } from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
createApp(App) // [1]
.use(store) // [2]
.use(router) // [3]
.mount('#app') // [4]
[1]
Création de l'application globale Vue à partir du composant parent de tous les autres :App.vue
[2]
Ajout du store de l'application (Plus de détails plus tard)[3]
Ajout du router de l'application, qui permet d'utiliser les composantsrouter-link
etrouter-view
dans notre application[4]
Montage de l'application dans l'élément qui a dans son attributid
la valeurapp
. Cet élément se trouve à la ligne 14 du fichierclient/public/index.html
(qui est le seul fichierHTML
du projet)
# Ajouter une nouvelle route
Pour ajouter une nouvelle route dans notre application, il faut :
- Ajouter un nouveau composant
- Ajouter un élément au tableau de routes et indiquer ce nouveau composant pour cette route
Nous allons ajouter une route /currencies
et un composants Currencies.vue
# Importer les composants des routes
Il y a deux façon d'importer les composants : import statiques et import dynamiques.
Par convention, les fichiers des composants des routes principales se mettront dans le dossier src/views
. Les composants qui seront utilisés dans plusieurs composants se mettront dans le dossier src/components
. Pour les autres, notamment ceux qui sont dédiés à une seule "view", c'est à l'équipe de décider d'une convention.
Les imports statiques sont plus simples à écrire et à gérer pour le développeur, mais implique que tous les composants sont importés dans le même bundle (ensemble de fichiers javascript et de SFC d'une application), ce qui aboutit à un gros fichier à télécharger pour l'utilisateur.
Vue-router permet de faire facilement ce qu'on appelle du "code-splitting" et du lazy-loading, c'est-à-dire une séparation du code en fragment, avec un chargement à la volée (opens new window).
Pour cela, il faut importer les composants en utilisant les imports dynamiques.
import Home from '@/views/Home.vue' // import statique
const About = () => import('@/views/About.vue') // import dynamique :
// Home est une fonction qui renvoie une promesse
// (la fonction import() renvoie une promesse)
export default new Router({
mode: 'history', // mode history, recommandé
// (sans # dans l'URL, mais demande une
// configuration du serveur web)
base: '/gmib',
routes: [
{
path: '/',
name: 'Home',
component: Home, // Ce composant est importé statiquement,
// car il y a de très fortes chances
// qu'il soit utilisé en premier et
// qu'il soit utilisé de toute façon
},
{
path: '/about',
name: 'About',
component: About, // Ce composant est importé à la volée :
// il ne sera importé que si l'utilisateur
// demande à accéder à la route /about
},
]
})
Vue-router comprend que About est une fonction et sait le gérer comme un composant "normal".
# Utilisation des composants router-view
et router-link
L'ajout de VueRouter en tant que plugin permet de mettre à disposition, dans tous les composants, des composants router-view
et router-link
. Voyons à quoi ils servent.
Dans le composants App.vue
, qui contient l'application en entier, le composant <router-link>
est utilisé (l. 4
et 5
) là où en HTML natif on utiliserait une balise <a>
:
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</div>
</template>
Ce composant permet à Vue d'utiliser l'API history (avec pushState
et replaceState
, cf. plus haut) de façon transparente pour le développeur. Le clic sur ces liens générés par Vue permet d'afficher le bon composant et de changer l'URL dans la barre d'URL du navigateur sans provoquer de rechargement de la page.
La props to
passée au composant <router-link to="/">
est soit une chaîne de caractères (String
), soit un objet avec une propriété qui permet de pointer vers une route unique. Il est recommandé d'utiliser la propriété name
. Pour notre fichier de routes écrit plus haut, cela donnerait ceci :
<template>
<div id="app">
<div id="nav">
<router-link :to="{ name: 'Home' }">Home</router-link> |
<router-link :to="{ name: 'About' }">About</router-link>
</div>
<router-view/>
</div>
</template>
Rappel : pour que { name: 'Home' }
soit évalué en tant que littéral objet et non en tant que chaîne de caractère, il faut faire précéder le nom de la props du caractère :
.
Des routes peuvent avoir des routes enfants :
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/about',
name: 'About',
component: About,
children: [
{
path: 'founders', // correspond à la route /about/founders
name: 'AboutFounders',
component: AboutFounders,
},
{
path: 'company', // correspond à la route /about/company
name: 'AboutCompany',
component: AboutCompany,
},
]
},
]
Parfois, on a besoin de routes dynamiques :
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/currencies',
name: 'CurrenciesHome',
component: Currencies,
children: [
{
path: ':currency',
name: 'Currency',
component: Currency,
},
]
},
]
À la ligne 13
, le chemin commence par un caractère deux-points :
, suivi du mot currency
. Dans le composant Currency
, on pourra retrouver cela sous forme de paramètre de la route, comme ceci : this.$route.params.currency
.
Exemple :
Avec le composant Currency
suivant :
<template>
<div id="currency">
{{ this.$route.params.currency }}
</div>
</template>
Si on appelle la route /currencies/bitcoin
, le composant affichera 'bitcoin' dans le <div id="currency">
.
On peut utiliser ce paramètre pour aller chercher des informations concernant ce paramètre :
<template>
<div>
{{ infoAboutThisCurrency }}
</div>
</template>
<script>
import fetchInfoAboutCurrency from '@/api/currencies-api'
export default {
name: 'Currency',
data () {
return {
infoAboutThisCurrency: undefined,
}
},
mounted () {
fetchInfoAboutCurrency(this.$route.params.currency)
.then(data => {
this.infoAboutThisCurrency = data
})
}
}
</script>
Notes :
- l'implémentation de la fonction
fetchInfoAboutcurrency
n'est pas donnée ; - dans un vrai composant, la template serait sans doute plus compliquée
# Configurer le proxy vers l'API
TODO: expliquer pourquoi
Rajouter un fichier vue.config.js
avec ce contenu :
module.exports = {
devServer: {
proxy: {
'^/api': {
target: 'http://localhost:3000',
}
}
}
}
# La partie back ou server ou API
# La base du projet
- créer un répertoire
server
- aller dedans et créer un
package.json
minimal avec la commandenpm init -y
Le fichier devrait ressembler à ceci :
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
- installer express en tant que dépendance de production avec la commande
npm i express
(par défaut, ceci est équivalent ànpm i -S express
qui sauvegarde la ou les dépendances en tant que dépendances de production) - installer nodemon en tant que dépendance de développment avec la commande
npm i -D nodemon
(à noter : l'option-D
pour Development dependencies)
Ceci va créer deux nouvelles propriétés dans le package.json
: dependencies
et devDependencies
.
Le fichier package.json
devrait maintenant ressembler à ceci :
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1"
},
"devDependencies": {
"nodemon": "^2.0.4"
}
}
- créer un fichier
index.js
avec ce contenu minimal (opens new window) :
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
On peut exécuter ce code dans node avec la commande node index.js
Le terminal devrait afficher la ligne suivante :
Example app listening at http://localhost:3000
Si on visite le site http://localhost:3000 avec son navigateur, on doit voir apparaître le texte Hello World!
.
- arrêter le serveur avec
Ctrl-c
- créer un répertoire
src
où seront tous les fichiers de l'application : à la racine ne se trouveront que des fichiers de configuration - déplacer le fichier
index.js
dans ce répertoiresrc
- modifier le fichier
package.json
pour lancer le server avec la commandenpm start
:
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1"
},
"devDependencies": {
"nodemon": "^2.0.4"
}
}
- pour que le serveur se relance automatiquement à chaque changement de notre code, il faut utiliser un autre outil qui surveille (monitor) les fichiers et leurs changemnents :
nodemon
, qui est installé à une étape précédente. Modifier donc le fichierpackage.json
pour avoir un scriptdev
qui lancenodemon src/index.js
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1"
},
"devDependencies": {
"nodemon": "^2.0.4"
}
}
- vérifier que le serveur démarre bien quand on lance la commande
npm run dev
(à noter : le mot-clérun
, contrairement au scriptstart
pour lequel ce n'est pas nécessaire).
Le terminal devrait afficher ceci :
****> server@1.0.0 dev /home/stan/projects/esilv/fullstack-app/server
> nodemon src/index.js
[nodemon] 2.0.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node src/index.js`
Example app listening at http://localhost:3000
# Ajouter des routes
# Signification des premières lignes de notre application
Les routes côté back, avec expressjs
(opens new window) se gère soit au niveau de l'app
pour la ou les routes principales, et avec des Router
s pour les autres routes.
Les lignes suivantes :
app.get('/', (req, res) => {
res.send('Hello World!')
})
Peuvent s'écrire de la façon suivante :
app.get( //[1]
'/', // [2]
(req, res) => {
res.send('Hello World!') // [3]
}
)
[1]
Pour toutes les requête de typeGET
...[2]
... Sur le chemin racine/
...[3]
... renvoyer (send
) dans la réponse (res
) la chaîneHello World!
Ensuite :
app.listen( // [1]
port, // [2]
() => { // [3]
console.log(`Example app listening at http://localhost:${port}`) // [4]
}
)
[1]
Écoute...[2]
...sur le port dont la valeur est contenu dans la variableport
...[3]
...et dès que tu es prêt à recevoir des requêtes, affiche dans la sortie standard la chaîneExample app listening at http://localhost:${port}
(${port}
sera remplacé par le numéro de port)
# Changer le préfixe de toutes les routes de notre API
Il est prudent de préfixer les appels à notre API pour signaler qu'il s'agit d'une API RESTful et la version de notre API.
const mainRouter = require('./routes/index.js')
// ( ... )
app.use('/api/v1', mainRouter) // [1]
[1]
Pour toutes les routes commençant par/api/v1
, utilise le routermainRouter
Voyons maintenant ce que contiendra ce mainRouter
Dans un premier temps, faire simple, ajouter une route GET /currencies
et renvoyer un objet avec une propriété success
à true
et une propriété currencies
avec un tableau de chaînes de caractères avec des noms de crypto-monnaies :
// fullstack-app/server/src/routes/index.js
const express = require('express') // [1]
const router = new express.Router() // [2]
router.get( // [3]
'/currencies', // [4]
(req, res) => {
const currencies = [ // [5]
'Bitcoin',
'Ethereum',
'Tether',
'Chainlink',
'Litecoin',
]
const responseData = { // [6]
success: true, // [7]
currencies: currencies, // [8]
}
res.json(responseData) // [9]
}
)
module.exports = router // [10]
[1]
import deexpress
[2]
création d'un nouveau router express[3]
pour toutes les requêtes GET...[4]
...sur le chemin/currencies
(plus le préfixe/api/v1
)...[5]
création d'un objet currencies qui est un array (tableau) de noms de crypto-monnaies[6]
création d'un objet avec les données qui seront envoyées dans la réponse :[7]
une propriétésuccess
àtrue
[8]
une propriétécurrencies
avec la liste de noms de crypto-monnaies
[9]
envoi de la réponse avec les données à renvoyer au client[10]
export du router
# Test de la route avec l'extension REST client
Il existe une extension appelée REST client (opens new window) qui permet de tester très facilement une API RESTfull.
Créer un fichier nommée api.http
dans un répertoire tests
ou bien à la racine du projet avec ce contenu :
@apiPrefix = /api/v1
@port = 4000
@host = localhost
@baseUrl = http://{{host}}:{{port}}{{apiPrefix}}
@currenciesPath = {{baseUrl}}/currencies
###
GET {{currenciesPath}} HTTP/1.1
Résultat après installation de l'extension, et lancement du serveur (npm run dev
dans le répertoire server
) :
Avec cette extension, un lien apparaît automatiquement au-dessus de la ligne
GET {{currenciesPath}} HTTP/1.1
Ce lien permet de faire un appel GET à l'adresse contenu dans la variable currenciesPath
qui vaut http://localhost:4000/api/v1/currencies
.
Juste après le clic sur ce lien, l'éditeur se divise et dans un onglet à droite apparaît la réponse de l'API :
- la première ligne décrit le protocole utilisé (
HTTP/1.1
) ; - le code de réponse en chiffre (ici
200
) ; - puis en texte (
OK
) ; - viennent ensuite les en-têtes de la réponse ; un en-tête par ligne ;
- puis un saut de ligne ;
- et enfin le corps (body) de la réponse, avec le JSON correspondant à l'objet envoyé dans le fichier
routes/index.js
.
# Ajouter des routes avec des paramètres
Express permet de créer des routes dynamiques avec des paramètres que l'on peut récupérer dans la requête, exactement de la même façon que côté front avec la bibliothèque 'vue-router' (et avec la même syntaxe).
Voici un exemple :
router.get( // [1]
'/currencies/:currencyslug', // [2]
function getRoot(req, res) {
const crypto = req.params.currencyslug // [3]
const path = '/cryptocurrency/info?slug=' + crypto [4]
fetchCMC(path) // [5]
.then(crypto => {
res.json({
success: true,
crypto // [6]
})
})
.catch(error => {
res.json({ // [7]
success: false,
message: error.message
})
})
})
const apiBaseUrl = 'https://pro-api.coinmarketcap.com/v1'
function fetchCMC (path) {
const apiKey = process.env.CMC_API_KEY // [8]
return fetch(`${apiBaseUrl}${path}`, {
method: "GET",
headers: {
accept: 'application/json',
'x-cmc_pro_api_key': apiKey // [9]
}
})
.then(fetchResponse => {
return fetchResponse.json();
})
.then(responseData => {
const errorCode = responseData.status.error_code // [10]
if (errorCode != 0) { // [11]
throw new Error(responseData.status.error_message) // [12]
}
return responseData.data // [13]
})
}
Le plus important dans ce code, ce sont les lignes **[2]
**et [3]
[1]
Pour toutes les requêtesGET
...[2]
...sur le chemin/currencies/:currencyslug
: ici,:currencyslug
correspond à une chaîne de caractères qui sera récupéré dans...[3]
...la propriétécurrencyslug
de la propriétéparams
de la requêtereq
[4]
...ce qui nous permet de créer dynamiquement une URL vers l'API CoinMarketCap (CMC)[5]
...qui est interrogé...[6]
...et si tout se passe bien, on envoie le résultat au client qui a effectué la requête[7]
sinon, une erreur avec le message de CoinMarketCap est renvoyé au client, dans la propriétémessage
[8]
La clé de l'API CoinMarketCap est secrète, et est donc récupérée depuis les variables d'environnement* pour...[9]
...être envoyé dans l'en-têtecmc_pro_api_key
, tel que l'attend CMC[10]
l'API CMC renvoie un JSON avec une propriétéerror_code
...[11]
... qui vaut 0 s'il n'y a pas d'erreur, et une autre valeur s'il y a eu une erreur.[12]
S'il y a eu une erreur, on la retourne au client,[13]
sinon, c'est le contenu de la propriété data qui est retourné
*C'est la bibliothèque dotenv
qui est utilisée pour récupérer les variables d'environnement mis dans un fichier .env
à la racine du dossier server
.
TODO: Expliquer les variables d'environnement, et la bibliothèque dotenv
Plutôt que d'avoir deux routes qui commencent par /currencies
dans un même fichier avec toutes les autres routes, il est conseillé de séparer dans un fichier propre toutes les routes qui commencent par le même slug. Dans routes/index.js
, par exemple, on peut écrire ceci :
// routes/index.js
// (...)
const currenciesRoutes = require('./currencies-routes.js')
// (...)
router.use('/currencies', currenciesRoutes)
// (...)
et
const express = require('express')
const router = new express.Router()
router.get('/', function getRoot(req, res) {
// (...)
}
router.get('/:currencyslug', function getRoot(req, res) {
const currencyslug = req.params.currencyslug
// (...)
}
module.exports = router
Cf. routes/index.js
(opens new window) et router/currencies-routes.js
(opens new window)
# Sécuriser des routes avec un jeton d'accès côté serveur
Pour sécuriser certaines routes, il convient d'utiliser un jeton (token) créé par le serveur, signé et difficilement falsifiable.
Les JWT sont très communément utilisé pour les applis web. Cf. Le site jwt.io (opens new window)
# La bibliothèque jsonwebtoken
La bibliothèque jsonwebtoken
, disponible sur npm, met à disposition un objet avec 2 fonctions qui vont nous servir :
- une fonction pour créer un JWT signé avec une date d'expiration
- une autre fonction pour valider un JWT, dans sa forme, sa signature et sa date d'expiration
jwt.sign(payload, secret, options)
payload
est la partie qui nous permettra de tracer l'utilisateur qui a demandé ce jeton (on met en général son ID de la base de données et/ou son nom d'utilisateur - username ou login).secret
est une chaîne de caractères, plus elle est compliquée, mieux c'est, et sert à la signature du JWToptions
contient certaines données, notamment la durée de validité du JWT
# Créer une route pour envoyer des identifiants
Pour envoyer un corps dans la requête, il faut faire une requête POST
, PUT
ou PATCH
.
# Rappels REST
Une API RESTful suit certaines conventions :
Une requête de type | permet de | comme | et en cas de succès, renvoie un code |
---|---|---|---|
GET | récupérer une ressource | une liste de crypto-monnaies | 200 |
POST | créer une ressource | un JWT, ou un utilisateur | 201 |
PUT | modifier une ressource | un utilisateur | 200 |
PATCH | modifier une portion de ressource | l'adresse email d'un utilisateur | 200 |
Pour plus d'informations sur les codes de retours des requêtes HTTP, voir le site httpstatuses.com (opens new window)
router.post('/auth/token', (req, res) => { // [1]
const authorizedLogin = process.env.AUTHORIZED_LOGIN // [2]
const authorizedPasswd = process.env.AUTHORIZED_PASSWD // [2]
const secret = process.env.SECRET // [3]
const body = req.body
if (!body || !body.login || !body.password) { // [4]
res.json({
success: false,
message: 'Login and password are required'
})
return
}
if (body.password !== authorizedPasswd || body.login !== authorizedLogin) { // [5]
res.json({
success: false,
message: 'Invalid credentials'
})
return
}
const payload = { // [6]
login: body.login,
}
const options = {
expiresIn: '1h', // [7]
}
const token = jwt.sign(payload, secret, options) // [8]
res.json({
success: true,
token // [9]
})
})
[1]
Gestion des requêtesPOST
sur la route/api/v1/auth/token
[2]
Récupération des bons identifiants[3]
Récupération de la phrase secrète pour signer le JWT[4]
Vérification que les identifiants (login et mot de passe) sont bien envoyés[5]
Vérification que les identifiants sont valides[6]
Création de la payload : le login de l'utilisateur sera stocké dans le JWT[7]
Création des options du JWT : il sera valable 1 heure[8]
Création du JWT avec la signature[9]
Envoi du token au client
Maintenant que le JWT est envoyé aux clients qui en font le demande correctement, il va falloir vérifier qu'il envoie ce JWT à chaque requête, en tout cas pour toutes les routes qu'on souhaite protéger.
# Vérifier le token dans les en-têtes
# Récupérer le token depuis les en-têtes
Le JWT se trouvera dans l'en-tête Authorization
, précédé de la chaîne de caractères Bearer
(Cf. la partie "How do JSON Web Tokens work?" de l'introduction de jwt.io (opens new window))
Les en-têtes d'une requêtes peuvent se récupérer avec la méthode header
:
function verifyToken (req, res, next) {
// (...)
const token = req // [1]
.header('Authorization') // [2]
.replace('Bearer ', '') // [3]
[1]
Création d'une variabletoken
qui contiendra le JWT[2]
récupération de l'en-têteAuthorization
[3]
suppression du préfixeBearer
pour ne récupérer que le JWT
# Vérifier le token avec jsonwebtoken
const jwt = require('jsonwebtoken') // [1]
// (...)
const secret = process.env.SECRET // [2]
jwt.verify(token, secret) // [3]
[1]
Import du modulejsonwebtoken
[2]
Récupération de la phrase secrète pour la...[3]
...vérification du JWT
N.B. : C'est une bonne pratique de ne récupérer la phrase secrète et de rassembler toutes les action sur les JWT dans un seul fichier. C'est ce qui a été fait sur l'appli d'exemple. Cf. token-utils.js
(opens new window)
# Ajouter la vérification du token pour toutes les routes protégées
# Créer un middleware
Un middleware est une fonction qui prend 3 paramètres : la requête et la réponse, comme les contrôleurs, et en plus une fonction, qui est par convention appelée next
en troisième et dernier paramètre.
Si next
est appelé sans paramètre, l'exécution se poursuit au prochain middleware ou au contrôleur. Sinon, le middleware peut aussi renvoyer directement la réponse. Dans notre cas, si le JWT n'est pas valide, il faudra renvoyer immédiatement une réponse avec un code 401
(cf. 401 sur httpstatuses (opens new window)).
Si le JWT n'est pas valide, la méthode jwt.verify(token, secret)
lèvera une erreur, et il faudra l'attraper (catch) et renvoyer une réponse 401
.
Voici le code final de notre middleware :
const tokenUtils = require('../utils/token-utils.js')
function verifyToken (req, res, next) {
try {
const token = req.header('Authorization').replace('Bearer ', '')
tokenUtils.checkToken(token) // [1]
next() // [2]
} catch (error) {
res
.status(401) // [3]
.json({
success: false,
message: 'Invalid token' // [4]
})
}
}
module.exports = verifyToken
[1]
Vérification du JWT (checkToken
appelle la fonctionjwt.verify()
avec les bons paramètres)[2]
Si on arrive à cette ligne, c'est que lecheckToken
n'a pas levé d'erreur[3]
SicheckToken
lève une erreur, on passe directement dans le blockcatch
et on renvoie donc un statut401
[4]
Avec un message compréhensible
# Ajouter le middleware pour les routes protégées
Pour ajouter le middleware pour les routes qu'on veut protéger, il suffit de l'ajouter en paramètre de la fonction use
(ou get
, ou post
ou put
, etc. si on ne veut protéger que certaines requêtes), entre le chemin et le contrôleur, le routeur, ou les autres middlewares.
Ici, on ajoute le middleware pour toutes les requêtes dont le chemin commence par /api/v1/currencies
:
const verifyToken = require('../middlewares/verify-token.js')
const currenciesRoutes = require('./currencies-routes.js')
const router = new express.Router()
router.use('/currencies', verifyToken, currenciesRoutes)
// ↑ ↑ ↑
// Chemin Middleware Routeur
// (...)
N.B. : On peut ajouter autant de middleware que l'on veut avant le contrôleur
# La gestion des JWT côté client
Côté client, il faudra :
- Récupérer le JWT dans la réponse à une authentification
- Le renvoyer dans l’en-tête de chaque requête qui demande une authentification
# Récupérer le jeton (à venir)
authenticate(login, password) // fonction qui appelle fetch en POST avec login et password dans le body
.then(data => {
if (!data.success) {
// Affiche le contenu de data.message dans l’interface web de l’application
return
}
// Utiliser data.token et le stocker côté client
})
Exemple d’implémentation de la fonction authenticate
:
/**
* @async
* @function
*
* @param {string} login - Nom d’utilisateur
* @param {string} password - Mot de passe de l’utilisateur
*
* @returns {Promise.<Object>}
*/
function authenticate (login, password) {
return fetch('/api/auth/token', {
method: 'POST',
body: JSON.stringify({
login,
password,
})
})
.then(res => res.json())
}
# Le localstorage
Le localStorage (stockage local) est une base de données interne au navigateur. Les données y sont stockées sous forme de paires de clé-valeur. On ne peut y stocker que des chaînes de caractères. Le localStorage est propre à un nom de domaine, et persiste lors d’un rechargement de page ou même si on quitte le navigateur et qu’on revient sur le même domaine (sauf si on a demandé à son navigateur de supprimer le localStorage à chaque fois qu’on le ferme).
# Enregistrer une donnée
localStorage.setItem(<clé_sous_forme_de_string>, <valeur_sous_forme_de_string>)
Exemple :
localStorage.setItem('token', data.token)
# Lire une donnée
const myVar = localStorage.getItem(<clé_sous_forme_de_string>)
Exemple :
const token = localStorage.getItem('token')
# Supprimer une donnée
localStorage.removeItem(<clé_sous_forme_de_string>)
Exemple :
localStorage.removeItem('token')
# Stocker le jeton dans le localstorage
authenticate(login, password) // fonction qui appelle fetch en POST avec login et password dans le body
.then(data => {
if (!data.success) {
// Affiche le contenu de data.message dans l’interface web de l’application
return
}
localStorage.setItem('token', data.token) // On stocke le contenu de la variable
})
# Le rajouter dans l'en-tête de toutes les requêtes qui se font sur des routes protégées
const getCurrencies = () => {
const token = localStorage.getItem('token')
return fetch('/api/currencies', { // Requête fetch, GET par défaut
headers: { // Ajoute des en-têtes
Authorization: 'Bearer ' + token // Ajoute un en-tête 'Authorization' avec Bearer concaténé avec le JWT
}
})
}
# Aller plus loin avec le localStorage
Vous ne devriez pas en avoir besoin, mais si jamais vous avez besoin de stocker un objet (un littéral objet ou un tableau) dans le localStorage, comme vous ne pouvez enregistrer que des String, vous pouvez utiliser la méthode statique JSON.stringify(<objet>)
pour l’enregistrer dans le localStorage :
localStorage.setItem('thor', JSON.stringify({ name: 'Thor' }))
Et pour le récupérer sous forme d’objet, il faudra utiliser la méthode JSON.parse(<string>)
:
const objAsString = localStorage.getItem('thor')
const obj = JSON.parse(objAsString)
Pour plus de détails, voir le MDN sur JSON.parse()
(opens new window) et sur JSON.stringify
(opens new window)
# Aller plus loin avec JSON.stringify()
et JSON.parse()
Avec les méthodes de l’objet global JSON
, on peut, côté serveur, créer une base de données sous forme de fichier :
# Fichier de gestion de la base de données
Fichier db-util.js
:
const fs = require('fs') // Module intégré à node, inutile de l’installer
const util = require('util')
const writeFile = util.promisify(fs.writeFile)
const readFile = util.promisify(fs.readFile)
const dbFile = './db.json'
// Fonction qui prend un objet, le transforme en string
// et enregiste cette string dans un fichier JSON lisible par un humain,
// et qui retourne une promesse (promise)
const saveDb = (db) => writeFile(dbFile, JSON.stringify(db, null, ' '))
// Fonction qui retourne une promesse, avec la base de données entière sous forme d’objet dans la promesse
const getDb = () => readFile(dbFile).then(str => JSON.parse(str))
module.exports = {
saveDb,
getDb,
}
# Utilisation
Contenu de départ du fichier de la base de données db.json
(doit être dans le même répertoire que le fichier db-util.js
) :
{}
Exemple d’utilisation :
const dbUtil = require('./db-util')
dbUtil.getDb()
.then(db => {
db.prop = 'value'
return db
})
.then(db => dbUtil.saveDb(db))
Contenu du fichier db.json
après l’exécution du fichier précédent :
{
"prop": "value"
}