# Développer un projet fullstack en JavaScript

# Outils

Pour développer en fullstack, nous utiliserons un grand nombre d'outils.

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

# Pour le back

# Préalable

  1. Créer un répertoire pour le projet fullstack (celui qui contiendra le dossier client et le dossier server)
  2. ouvrir ce projet avec VS Code
  3. ouvrir le terminal intégré de VS Code
  4. initialiser un projet git avec la commande git init
  5. créer un fichier .gitignore (doit commencer par un point .) avec le contenu créé sur gitignore.io (opens new window) avec les options Node, Code, Linux, Windows, et macOS Création du contenu de .gitignore
  6. 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
  1. ajouter ces 2 fichiers à l'index avec la commande git add .gitignore .editorconfig
  2. créer un commit avec la commande git commit -m ":tada: Init fullstack project"

# La partie front ou client

  1. Installer ou mettre à jour la dernière version de @vue/cli avec la commande npm i -g @vue/cli (éventuellement précédée de sudo si vous êtes sur MacOS et que vous n'utilisez pas nvm (opens new window))
  2. 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épertoire client)

Sélectionner les fonctionnalités suivantes :

Sélection des fonctionnalités avec @vue/cli

Choisir Vue 3 (parce qu'on est moderne) :

Sélection de Vue 3 avec @vue/cli

Choisir History mode (opens new window) :

Sélection de History mode avec @vue/cli

Je recommande la config Standard (opens new window) pour Eslint :

Sélection de eslint + Standard avec @vue/cli

Sélectionner de formatter le code au moment du commit (git (opens new window) hooks (opens new window), on verra ça plus tard) :

Sélection de formattage au commit avec @vue/cli

Sans grande importance, je recommande des fichiers séparés pour les différentes config, plutôt que de tout mettre :

Sélection de fichiers de config dédiés avec @vue/cli

Inutile de sauvegarder cette sélection (laisser par défaut - N) :

Sélection de non sauvegarde des sélections avec @vue/cli

# 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)

Et les extensions suivantes avec des configurations supplémentaires :

  • eslint (opens new window)

    • 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
     ],
    
  • stylelint (opens new window)

    • avec ces paramètres :
     "css.validate": true,
     "less.validate": true,
     "scss.validate": true,
    

# Extensions dédiées à 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 :

Application simple Vue

# 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 composants router-link et router-view dans notre application
  • [4] Montage de l'application dans l'élément qui a dans son attribut id la valeur app. Cet élément se trouve à la ligne 14 du fichier client/public/index.html (qui est le seul fichier HTML 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".

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

  1. créer un répertoire server
  2. aller dedans et créer un package.json minimal avec la commande npm 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"
}
  1. 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)
  2. 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"
 }
}
  1. 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!.

  1. arrêter le serveur avec Ctrl-c
  2. créer un répertoire src où seront tous les fichiers de l'application : à la racine ne se trouveront que des fichiers de configuration
  3. déplacer le fichier index.js dans ce répertoire src
  4. modifier le fichier package.json pour lancer le server avec la commande npm 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"
 }
}
  1. 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 fichier package.json pour avoir un script dev qui lance nodemon 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"
  }
}
  1. vérifier que le serveur démarre bien quand on lance la commande npm run dev (à noter : le mot-clé run, contrairement au script start 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 Routers 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 type GET...
  • [2] ... Sur le chemin racine /...
  • [3] ... renvoyer (send) dans la réponse (res) la chaîne Hello 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 variable port...
  • [3] ...et dès que tu es prêt à recevoir des requêtes, affiche dans la sortie standard la chaîne Example 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 router mainRouter

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 de express
  • [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) :

Test avec REST client

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êtes GET...
  • [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ête req
  • [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ête cmc_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 JWT
  • options 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êtes POST 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 variable token qui contiendra le JWT
  • [2] récupération de l'en-tête Authorization
  • [3] suppression du préfixe Bearer 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 module jsonwebtoken
  • [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 fonction jwt.verify() avec les bons paramètres)
  • [2] Si on arrive à cette ligne, c'est que le checkToken n'a pas levé d'erreur
  • [3] Si checkToken lève une erreur, on passe directement dans le block catch et on renvoie donc un statut 401
  • [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"
}