Service REST en JavaScript, sans cadriciel

Il existe plusieurs cadriciels (Frameworks) en JavaScript pour faciliter l'exposition de services Web de type REST. Cependant, utiliser un cadriciel tend à masquer les détails, et à obscurcir la compréhension de ce qui se passe vraiment.

L'exercice qui suit vise donc à mettre en place « manuellement » un service Web de type REST. Le service sera simple, mais la mécanique sera pleinement exposée et, souhaitons-le, le tout sera moins « magique » si vous utilisez un cadriciel par la suite pour en arriver à faire une tâche semblable.

Quelques raccourcis :

Bien que ce qui suit soit en « pur JavaScript » sans le moindre cadriciel, il s'agit de code « côté serveur ». Vous voudrez donc au préalable installer Node.js : https://nodejs.org/

L'API que nous mettrons en place supportera deux verbes http, soit GET et POST. Il est facile de tester une requête GET à l'aide d'un fureteur, mais vous voudrez probablement installer Postman pour tester la requête POST : https://www.postman.com/

Idée générale

Le service que nous exposerons se déclinera en deux volets, soit :

Comme le veut l'usage dans le style REST, les services seront modélisés par des requêtes http utilisant des verbes http à titre de services. La requête pour obtenir la liste des cours se fera à l'aide du verbe GET, et la requête pour ajouter un cours à la liste se fera à l'aide du verbe POST.

Structure du service

Notre service sera réparti en trois (en fait, quatre; voir source de données, plus bas) fichiers :

Ce modèle peut être transposé à des services plus complexes; évidemment, si le service se décline en plusieurs fonctionnalités, un découpage plus fin (plus de fichiers, plus de fonctions) est à conseiller, quand bien même ce ne serait que pour en arriver à une solution plus réutilisable.

Source de données

Le quatrième fichier modélisera une source de données; pour que l'exemple demeure simple et essentiellement exempt de dépendances, j'éviterai d'utiliser une base de données, utilisant plutôt un tableau de données en format JSON. Il n'est pas essentiel au modèle pris dans son acception générale, mais sera utile pour ce petit exemple.

Puisque notre service donnera un accès en lecture / écriture à une liste de cours, et puisqu'il s'agit d'un exemple très simple, ce tableau se nommera cours et représentera chaque cours par un triplet JSON de la forme , par exemple :

module.exports = cours = [
 // ... bla bla
 {
   id: 3,
   nom: "Développement de services d'échange de données",
   sigle: '420-KBG-LG'
 },
 // ... bla bla
];

Requêtes http

Le style REST utilise des paires faites d'un verbe http et d'une URL pour identifier des services. Nous respecterons cet usage : dans notre exemple, nous associerons un service à une requête GET et un autre service à une requête POST, mais en pratique nous aurions pu offrir plusieurs services par verbe, en passant à travers plusieurs URL distinctes.

Les extraits qui suivent sont pris du fichier route.js, qui assure le relais entre une requête http et un appel de service. Le code complet se trouve plus bas. La forme générale du traitement dans ce fichier est :

  • Appeler la méthode http.createServer qui traitera les requêtes (req, pour request) et produira les réponses (res, pour response) à l'aide d'une fonction (ici, d'une λ) de notre cru
  • Tant que le programme s'exécutera
    • Quand requête est reçue, en examiner l'URL et le verbe http associés
    • Si cette paire correspond à l'un des services exposés, alors exécuter ce service

Dans notre code, les URL et les noms des services appelés correspondront l'un à l'autre (p. ex. : l'URL /getCours et la fonction coursOps.getCours), mais ce n'est pas nécessaire pour que le tout fonctionne; j'ai préparé le tout sous cette forme pour simplifier le repérage et la compréhension.

const http = require('http');
const url = require('url');
module.exports = http.createServer((req, res) => {
  // ...
  const reqUrl =  url.parse(req.url, true);
  // ...
  if(reqUrl.pathname == '/getCours' && req.method === 'GET') {
    // traitement associé au service 
  } else if(...) {
     // ... et ainsi de suite
  }
})

Support de GET

Dans notre exemple, un des services sera associé à une requête GET et à l'URL nommée /getCours. Ce service aura pour rôle de produire en tant que réponse une trame JSON contenant la liste des cours connus du service. La méthode getCours de l'objet coursOps (exposé par controller.js) réalisera ce traitement, et nous produirons une trace à la console pour faciliter le débogage.

const http = require('http');
const url = require('url');
module.exports = http.createServer((req, res) => {
  var coursOps = require('./controller.js');
  const reqUrl =  url.parse(req.url, true);
  if(reqUrl.pathname == '/getCours' && req.method === 'GET') { // <-- ICI
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.getCours(req, res);
  }
  // ... autres requêtes
})

Support de POST

Dans notre exemple, un des services sera associé à une requête POST et à l'URL nommée /createCours. Ce service aura pour rôle de modifier la liste des cours en y ajoutant une nouvelle entrée, dont le content sera inscrit à même req. La méthode createCours de l'objet coursOps (exposé par controller.js) réalisera ce traitement, et nous produirons une trace à la console pour faciliter le débogage.

const http = require('http');
const url = require('url');
module.exports = http.createServer((req, res) => {
  var coursOps = require('./controller.js');
  const reqUrl =  url.parse(req.url, true);
  if(reqUrl.pathname == '/getCours' && req.method === 'GET') {
     // ...
  } else if(reqUrl.pathname == '/createCours' && req.method === 'POST') { // <-- ICI
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.createCours(req, res);
  }
  // ... autres requêtes
})

Traitement des erreurs

Dans notre exemple simpliste, seuls deux services sont offerts, ce qui signifie que toute autre combinaison d'URL et de verbe http sera erronnée. Nous appellerons la méthode invalidUrl de l'objet coursOps (exposé par controller.js) pour traiter ce cas, et nous produirons une trace à la console pour faciliter le débogage.

const http = require('http');
const url = require('url');
module.exports = http.createServer((req, res) => {
  var coursOps = require('./controller.js');
  const reqUrl =  url.parse(req.url, true);
  if(reqUrl.pathname == '/getCours' && req.method === 'GET') {
    // ...
  } else if(reqUrl.pathname == '/createCours' && req.method === 'POST') {
    // ...
  } else { // requête invalide
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.invalidUrl(req, res);
  }
})

La structure proposée ici est simple et linéaire : examiner en séquence chaque paire (URL, verbe http) et appeler une fonction pour réaliser le traitement associé. Notez que cela signifie toutefois que, s'il y a plusieurs paires à examiner, plus la paire examinée sera loin dans la séquence et plus il sera coûteux de la détecter. Il peut donc être raisonnable d'examiner des structures alternatives si le service devient complexe.

Le code

Maintenant que nous avons jeté un oeil sommaire à la structure par laquelle notre serveur Web associera les services aux paires (URL, verbe http), examinons le code des quatre fichiers qui composent notre offre de service de type REST.

Fichier serveur.js

Le fichier serveur.js sera notre serveur Web. Il écoutera sur le port 2357 (choisi tout à fait arbitrairement) et le traitement des requêtes et des réponses http qui passeront par lui sera réalisé par l'objet représenté par le module route.js.

Pour le code entier :

const http = require('http');
const hostname = '127.0.0.1';
const port = 2357; // mettons
const server = require('./route.js'); // importer les routes
server.listen(port, hostname, () => {
  console.log('Serveur en exécution sur http://' + hostname + ':' + port + '/');
});

Que le code soit simple n'est pas un choc; Node.js est souvent utilisé pour écrire des serveurs Web, et les mécanismes pour y parvenir sont à la fois très sophistiqués et très simples d'approche.

Fichier route.js

Nous avons examiné ce fichier étape par étape dans la section Requêtes http, plus haut. Le code entier du fichier suit :

const http = require('http');
const url = require('url');
module.exports = http.createServer((req, res) => {
  var coursOps = require('./controller.js');
  const reqUrl =  url.parse(req.url, true);
  // GET endpoint
  if(reqUrl.pathname == '/getCours' && req.method === 'GET') {
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.getCours(req, res);
  // POST endpoint
  } else if(reqUrl.pathname == '/createCours' && req.method === 'POST') {
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.createCours(req, res);
  // URL invalide
  } else {
    console.log('Request type: ' + req.method + ' Endpoint: ' + req.url);
    coursOps.invalidUrl(req, res);
  }
})

Fichier controller.js

Le traitement à réaliser pour chaque service est logé dans controller.js. Plusieurs instructions exports (une par fonction à exposer) s'y logent. Notez qu'en l'absence d'une base de données, la source de données pour nos services sera un objet représenté sous forme JSON dans sourceDonnees.js.

Prenons tout d'abord les éléments clés un à un :

La fonction getCours dans cet exemple simpliste crée un tableau d'objets JSON dont le premier élément est un message d'introduction, et le second est le tableau de cours en tant que tel (la variable cours est le contenu exporté sous forme JSON par sourceDonnees.js).

Le code de retour 200 signifie que le traitement est un succès.

Deux détails importants :

  • L'en-tête de la trame de réponse indiquera que le contenu est sous forme JSON, pour que le destinataire puisse déduire une stratégie en vue de la consommer
  • Le contenu de la trame en tant que tel sera une version « chaîne de caractères » de la variable response dont le contenu est du JSON brut (binaire)
exports.getCours = function(req, res) {
  const reqUrl = url.parse(req.url, true)
  var response = [
    {
      "message": "Les cours de S5 sont "
    },
    cours
  ];
  res.statusCode = 200;
  res.setHeader('content-Type', 'Application/json');
  res.end(JSON.stringify(response))
}

La fonction createCours est plus sophistiquée en ce sens qu'elle peut accepter l'ajout de plusieurs cours en une seule requête. La validation dans cette fonction est nettement insuffisante, alors ne la prenez pas comme un exemple de code sécuritaire! Son fonctionnement va comme suit :

La variable body sert à titre de chaîne de caractères pour cumuler les cours à ajouter, sous forme de texte

La méthode on de l'objet req est un mécanisme de rappel (Callback). Ainsi :

Pour chaque élément de contenu dans la requête (paramètre 'data'), elle ajoutera cet élément (le texte chunk) à body, et elle ajoutera concrètement le cours représenté à la variable cours (je n'ai pas ajouté le code pour valider les sigles, éviter les doublons, etc.)

À la fin (paramètre 'end'), elle transformera le texte cumulé (variable body) en format JSON, créera la réponse (variable response), et signalera un succès (code 201, qui signifie Created)

 

exports.createCours = function(req, res) {
   body = '';
   
   req.on('data',  function (chunk) {
     body += chunk;
     cours = cours.concat(JSON.parse(chunk));
   });
   
   req.on('end', function () {
     postBody = JSON.parse(body);
     var response = [
       {
         "text": "Cours ajouté(s) avec succès"
       },
       postBody
     ]
     
     res.statusCode = 201;
     res.setHeader('content-Type', 'Application/json');
     res.end(JSON.stringify(response))
   })
}

La fonction invalidUrl jouera le rôle de traitement d'erreur très rudimentaire. Dans le cas d'une paire (URL, verbe http) incorrecte, elle signale le problème par un code 404 (introuvable) et émet une trame JSON listant les options valides.

exports.invalidUrl = function(req, res) {
   var response = [
     {
       "message": "Endpoint incorrect. Les options possibles sont "
     },
     availableEndpoints
   ]
   res.statusCode = 404;
   res.setHeader('content-Type', 'Application/json');
   res.end(JSON.stringify(response))
}

Pour appuyer la fonction invalidUrl dans son travail, la constante availableEndpoints liste les paires (URL, verbe http) possibles, de manière à faciliter la découverte de l'offre de services chez le client.

const availableEndpoints = [
  {
    method: "GET",
    getCours: "/getCours"
  },
  {
    method: "POST",
    createCours: "/createCours"
  }
]

Pour le code entier :

const url = require('url');
let cours = require('./sourceDonnees.js'); // let plutôt que const pour permettre la modification dans createCours

exports.getCours = function(req, res) {
  const reqUrl = url.parse(req.url, true)
  var response = [
    {
      "message": "Les cours de S5 sont "
    },
    cours
  ];
  res.statusCode = 200;
  res.setHeader('content-Type', 'Application/json');
  res.end(JSON.stringify(response))
}

exports.createCours = function(req, res) {
   body = '';
   
   req.on('data',  function (chunk) {
     body += chunk;
     cours = cours.concat(JSON.parse(chunk)); // très naïf
   });
   
   req.on('end', function () {
     postBody = JSON.parse(body);
     var response = [
       {
         "text": "Cours ajouté(s) avec succès"
       },
       postBody
     ]
     
     res.statusCode = 201;
     res.setHeader('content-Type', 'Application/json');
     res.end(JSON.stringify(response))
   })
}

exports.invalidUrl = function(req, res) {
   var response = [
     {
       "message": "Endpoint incorrect. Les options possibles sont "
     },
     availableEndpoints
   ]
   res.statusCode = 404;
   res.setHeader('content-Type', 'Application/json');
   res.end(JSON.stringify(response))
}
 
const availableEndpoints = [
  {
    method: "GET",
    getCours: "/getCours"
  },
  {
    method: "POST",
    createCours: "/createCours"
  }
]

Fichier sourceDonnees.js

Le fichier sourceDonnees.js joue pour cet exemple le rôle de pseudo-base de données, mais sans offrir de validation ou de persistance. Il se limite à un tableau de cours en format JSON; ce tableau peut être modifié par le programme, mais reprend sa forme initiale à chaque exécution. C'est, rappelons-le, un exemple de service REST que nous mettons en place ici, et non pas une solution réelle à un problème de service descriptif d'une offre de cours.

Pour le code entier :

module.exports = cours = [
 {
   id: 1,
   nom: "Professions de l'informatique",
   sigle: '420-KBF-LG'
 },
 {
   id: 2,
   nom: "Projet d'intégration",
   sigle: '420-KBH-LG'
 },
 {
   id: 3,
   nom: "Développement de services d'échange de données",
   sigle: '420-KBG-LG'
 },
 {
   id: 4,
   nom: "Conception de jeux vidéo",
   sigle: '420-KJ2-LG'
 },
 {
   id: 5,
   nom: "Conception d'environnements intelligents",
   sigle: '420-KF2-LG'
 }
];

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !