Activité 01 – Ébauche de solution

Ceci n'est qu'une ébauche de solution pour activité 01, pas une solution complète. L'idée est de vous orienter pour résoudre des problèmes connexes dans le futur, et de vous aider à aborder des situations semblables quand cela surviendra.

Étape 0 – s'inspirer du modèle

Puisqu'un modèle de services nous était confié, inspirons-nous en, et commençons par des tâches simples et mécaniques :

Ne reste qu'à se trouver un contrôleur existant à titre d'inspiration, puis on y va.

Étape 1 – choisir un exemple de départ

J'ai regardé un peu ce que faisaient les API existantes, et j'ai pris celle de contacts comme base, car cette API ressemble à ce que demande Activité 01 (en plus simple, évidemment).

Pour une simple requête GET de base (p. ex. : GET sur l'URI qu'est /api/bookmarks), de même que pour un GET par identifiant (p. ex. : GET sur /api/bookmarks/3 pour le signet dont le champ Id vaut 3), c'est presque déjà fait. On remplace le mot contact par le mot bookmark dans le code de BookmarksController et ça fonctionne tel quel.

Faut ajuster un peu plus le code pour les autres requêtes GET par contre, soit celles qui comportent des paramètres. Le temps est venu de se retrousser les manches...

Étape 2 – premiers ajustements

Sans grandes surprises, faut lire un peu le code existant pour comprendre la mécanique qui y existe déjà; on ne peut pas vraiment avancer par chance (on peut, mais c'est pas une technique fiable), alors faut se faire un plan et prendre des notes.

Si on creuse un peu le code de router.js, qui relaie le code vers notre contrôleur, on constatera qu'il y a un couplage un peu malheureux entre la fonction de validation API_Endpoint_Ok et certaines extractions d'information de l'URI reçue, en particulier le paramètre Id s'il y en a un. C'est... embêtant, mais refactoriser à ce stade serait prématuré.

Si on examine API_Endpoint_Ok plus en détail, on constate une extraction de la queryString de la requête, mais dans un passage qui ne conserve pas les paramètres. On peut en profiter pour ajouter une variable (essentiellement globale, comme l'est id dans ce code...) nommée args qui conservera les paramètres de la requête.

Avant Après
let id = undefined;
// ...
function API_Endpoint_Ok(url){
   // ...
   let queryStringMarkerPos = url.indexOf('?');
   if (queryStringMarkerPos > -1)
      url = url.substr(0, url.indexOf('?'));
   // ...
let id = undefined;
let args = undefined;
// ...
function API_Endpoint_Ok(url){
   // ...
   let queryStringMarkerPos = url.indexOf('?');
   if (queryStringMarkerPos > -1) {
      if(queryStringMarkerPos + 1 !== url.length) {
         args = url.substr(queryStringMarkerPos + 1, url.length);
      }
      url = url.substr(0, queryStringMarkerPos);
   }
   // ...

Pour en tenir compte lors de l'appel des services get du contrôleur, on peut ajuster l'appel à la méthode get du contrôleur pour lui relayer les paramètres :

Avant Après
// ...
if (req.method === 'GET') {
   controller.get(id);
   // request consumed
   return true;
}
// ...
// ...
if (req.method === 'GET') {
   controller.get(id,args);
   // request consumed
   return true;
}
// ...

... et ajuster la méthode get pour qu'elle devienne d'arité 2 (à deux paramètres) dans le contrôleur pour en tenir compte :

Avant Après
// ...
// GET: api/bookmarks
// GET: api/bookmarks/{id}
get(id){
   if(!isNaN(id))
      this.response.JSON(this.bookmarksRepository.get(id));
   else
      this.response.JSON(this.bookmarksRepository.getAll());
}
// ...
// ...
// GET: api/bookmarks
// GET: api/bookmarks/{id}
// GET: api/bookmarks/...args...
get(id,args){
   if(args === undefined) {
      if(!isNaN(id))
         this.response.JSON(this.bookmarksRepository.get(id));
      else
         this.response.JSON(this.bookmarksRepository.getAll());
   } else {
      this.response.JSON(null); // en attendant
   }
}
// ...

Nous voilà prêts à commencer à programmer, à partir de ce nouveau else qui sera notre point de départ.

Étape 3 – on programme

Procédons à partir de maintenant par étapes :

Allons-y.

Décortiquer la queryString

Nous voudrons donc :

Pour permettre à des clés et à des valeurs de contenir des symboles spéciaux (comme = ou & par exemple), nous accepterons des délimiteurs autour d'eux. Pour cet exemple, je me limiterai au caractère '\"' (en JavaScript : '"'), mais il serait plus idiomatique de supporter aussi '\'' (en JavaScript : "'"). Notez toutefois que le traitement qui suit est trop naif pour gérer correctement ces caractères spéciaux.

Mon type de paire sera :

class Pair {
   constructor(key, value) {
      this.key = key;
      this.value = value;
   }
}

Ma fonction pour traiter la queryString se nommera parseArgs :

function parseArgs(args) {
   let res = [];
   let debut = 0;
   let fin = args.indexOf('&');
   while (fin != -1 && debut != fin) {
      res.push(parseArgument(args.substr(debut, fin)));
      debut = fin + 1;
      fin = args.indexOf('&', debut);
   }
   res.push(parseArgument(args.substr(debut, args.length)));
   return res;
}

Vous aurez noté que, suivant le plan de match annoncé plus haut, parseArgs (pluriel) délègue le traitement de chaque paramètre à parseArgument (singulier), que voici :

function parseArgument(str) {
   let pos = str.indexOf('=');
   let key = cleanArgument(str.substr(0, pos));
   let value = cleanArgument(str.substr(pos + 1, str.length));
   return new Pair(key, value);
}

La fonction cleanArgument élague ce qui n'est pas pertinent à chacune des string traitées, et utilise lui-même deQuotify qui élimine les guillemets englobants s'il y en a :

function deQuotify(s) {
   if(s == null || s.length <= 1) return s;
   let first = s[0];
   let last = s[s.length-1];
   if((first == '"' || last == '"') && first != last)
      throw "not well-formed quoted string";
   return first == '"'? s.substr(1, s.length - 2) : s;;
}

function cleanArgument(s) {
   return deQuotify(decodeURI(s).trim());
}

Créer une séquence de transformations

Une fois la chaîne décortiquée, nous voudrons générer une séquence de transformations tenant compte du fait qu'il peut y avoir plusieurs paramètres combinés dans le code client (p. ex. : name="..."&category="..."&sorted="name" par exemple). Nous appliquerons les transformations dans l'ordre où elles apparaissent, pour que le code client soit en contrôle de la qualité de sa propre expérience.

L'idée est de :

Pour simplifier le modèle, je lèverai une exception si un problème survient en cours de route

J'y irai de l'approche suivante :

Pour faciliter la compréhension, commençons par les opérations les plus concrètes, pour aller vers les plus abstraites.

Écrire un filtre

Un filtre sera une fonction qui prend une liste de valeurs et ne conserve que certaines d'entre elles. Dans notre cas, on parle par exemple de conserver les instances de Bookmark ayant un nom spécifique ou appartenant à une catégorie choisie.

Une implémentation possible serait :

function keepName(name) {
   return function(lst) {
      let res = [];
      for(let i = 0; i != lst.length; ++i) {
         if(cleanArgument(lst[i].Name) === name) {
            res.push(lst[i]);
         }
      }
      return res;
   }
}
function keepCategory(cat) {
   return function(lst) {
      let res = [];
      for(let i = 0; i != lst.length; ++i) {
         if(cleanArgument(lst[i].Category) === cat) {
            res.push(lst[i]);
         }
      }
      return res;
   }
}

Vous remarquerez que le code est très semblable, ce qui change étant le membre d'un Bookmark auquel nous accédons en pratique. Si nous faisons abstraction de cet accès à un membre à l'aide d'une fonction, on peut nettement simplifer le tout :

function keepIf(val,member) {
   return function(lst) {
      let res = [];
      for(let i = 0; i != lst.length; ++i) {
         if(cleanArgument(member(lst[i])) === val) {
            res.push(lst[i]);
         }
      }
      return res;
   }
}

function keepName(name) {
   return keepIf(name, function(bm) { return bm.Name; });
}

function keepCategory(cat) {
   return keepIf(cat, function(bm) { return bm.Category; });
}

Je n'ai pas implémenté les patterns comme abc* ou *def mais le modèle serait le même; il faudrait passer un prédicat en paramètre qui contrôlerait le test fait par keepIf, pour remplacer la comparaison brute avec === par quelque chose de plus fin au besoin.

Remarquez que la fonction keepIf retourne une fonction destinée à être appliquée plus tard. C'est une fabrique de fonctions.

Écrire un tri

Pour les besoins des tris, j'ai implémenté un banal tri à bulles (c'est un algorithme médiocre, mais l'idée est de garder l'exemple simple), tout en paramétrant le prédicat utilisé pour tester l'ordre selon lequel les objets sont placés. En gros :

function sort(lst,pred) {
   for(let i = 0; i < lst.length - 1; ++i)
      for(let j = i + 1; j < lst.length; ++j)
         if(!pred(lst[i], lst[j])) {
            let temp = lst[i];
            lst[i] = lst[j];
            lst[j] = temp;
         }
   return lst;
}

Dans le but de générer des fonctions de tri respectueuses des critères soumis par le code client, j'ai écrit une fabrique :

function sortBy(att) {
   if (att === 'name') {
      return function(lst) {
         return sort(lst, function(a,b) {
            return cleanArgument(a.Name) < cleanArgument(b.Name);
         });
      }
   } else if (att === 'category') {
      return function(lst) {
         return sort(lst, function(a,b) {
            return cleanArgument(a.Category) < cleanArgument(b.Category);
         });
      }
   } else {
      throw "bad sort criterion";
   }
}

On aurait pu faire plus simple avec un dictionnaire dont les clés seraient les critères selon lesquels trier et les valeurs seraient des prédicats. Si ça vous amuse, l'écrire ainsi peut être instructif.

Remarquez que la fonction sortBy, comme la fonction keepIf, retourne une fonction destinée à être appliquée plus tard. C'est une fabrique de fonctions.

Créer les transformations

Nous avons donc deux fabriques de transformations, soit keepIf et sortBy plus haut. Reste à choisir quelle transformation générer pour chaque paramètre reçu dans une requête GET donnée. À cette fin, j'utiliserai makeTransform ci-dessous :

// créer le tableau de fonctions
function makeTransform(key, value) {
   if(key === 'name') {
      return keepName(value);
   } else if (key === 'category') {
      return keepCategory(value);
   } else if (key === 'sort') {
      return sortBy(value);
   } else {
      throw "invalid operation name";
   }
}

Ne reste plus qu'à itérer à travers les paramètres reçus pour construire la séquence de transformations correspondante :

function buildProcess(pairs) {
   let funcs = [];
   for(let i = 0; i != pairs.length; ++i) {
      funcs.push(makeTransform(pairs[i].key, pairs[i].value));
   }
   return funcs;
}

Et voilà, l'infrastructure est en place. Reste à générer la réponse à la requête reçue.

Générer la réponse

À titre de rappel, notre version modifiée (d'arité 2) de la méthode get du BookmarksController a présentement la forme suivante :

// ...
// GET: api/bookmarks
// GET: api/bookmarks/{id}
// GET: api/bookmarks/...args...
get(id,args){
   if(args === undefined) {
      if(!isNaN(id))
         this.response.JSON(this.bookmarksRepository.get(id));
      else
         this.response.JSON(this.bookmarksRepository.getAll());
   } else {
      this.response.JSON(null); // en attendant
   }
}
// ...

C'est la ligne qui génère une réponse null que nous allons remplacer par le fruit de nos efforts. Il nous faudra d'abord consommer les paramètres pour en faire une liste de paires :

let parsedArgs = parseArgs(args);

... puis générer des transformations à partir de cette liste :

let funcs = buildProcess(parsedArgs);

... puis prendre l'ensemble des données disponibles à titre de point de départ :

let src = this.bookmarksRepository.getAll();

Ne reste plus qu'à transformer src en lui appliquant successivement chacune des transformations dans le tableau funcs :

for(let i = 0; i != funcs.length; ++i) {
   src = funcs[i](src);
}

À la fin, src contient ce que le client a demandé :

this.response.JSON(src);

Le code complet est donc :

// ...
// GET: api/bookmarks
// GET: api/bookmarks/{id}
// GET: api/bookmarks/...args...
get(id,args){
   if(args === undefined) {
      if(!isNaN(id))
         this.response.JSON(this.bookmarksRepository.get(id));
      else
         this.response.JSON(this.bookmarksRepository.getAll());
   } else {
      let parsedArgs = parseArgs(args);
      let funcs = buildProcess(parsedArgs);
      let src = this.bookmarksRepository.getAll();
      for(let i = 0; i != funcs.length; ++i) {
         src = funcs[i](src);
      }
      this.response.JSON(src);
   }
}
// ...

... et le tour est joué.

Simplifier un peu le tout

Nous avons quelques répétitives for avec indices dans nos exemples, or dans la plupart des cas nous souhaitons vraiment itérer sur les éléments, sans avoir réellement d'intérêt pour les indices. Les tableaux de JavaScript exposent un service forEach acceptant une λ et l'appliquant sur chacun des éléments parcourus.

Ceci permet d'aller plus à l'essentiel dans bien des cas. Comparez :

Avec boucle à indices Avec forEach
function keepIf(val,member) {
   return function(lst) {
      let res = [];
      for(let i = 0; i != lst.length; ++i) {
         if(cleanArgument(member(lst[i])) === val) {
            res.push(lst[i]);
         }
      }
      return res;
   }
}
function keepIf(val,member) {
   return function(lst) {
      let res = [];
      lst.forEach(elem => {
         if(cleanArgument(member(elem)) === val) {
            res.push(elem);
         }
      });
      return res;
   }
}
function buildProcess(pairs) {
   let funcs = [];
   for(let i = 0; i != pairs.length; ++i) {
      funcs.push(makeTransform(pairs[i].key, pairs[i].value));
   }
   return funcs;
}
function buildProcess(pairs) {
   let funcs = [];
   pairs.forEach(p => funcs.push(makeTransform(p.key, p.value)));
   return funcs;
}
get(id,args){
   if(args === undefined) {
      if(!isNaN(id))
         this.response.JSON(this.bookmarksRepository.get(id));
      else
         this.response.JSON(this.bookmarksRepository.getAll());
   } else {
      let parsedArgs = parseArgs(args);
      let funcs = buildProcess(parsedArgs);
      let src = this.bookmarksRepository.getAll();
      for(let i = 0; i != funcs.length; ++i) {
         src = funcs[i](src);
      }
      this.response.JSON(src);
   }
}
get(id,args){
   if(args === undefined) {
      if(!isNaN(id))
         this.response.JSON(this.bookmarksRepository.get(id));
      else
         this.response.JSON(this.bookmarksRepository.getAll());
   } else {
      let parsedArgs = parseArgs(args);
      let funcs = buildProcess(parsedArgs);
      let src = this.bookmarksRepository.getAll();
      funcs.forEach(f => src = f(src));
      this.response.JSON(src);
   }
}

On pourrait aller plus loin encore, mais ça donne une idée.

Une autre simplification facile à apporter et le remplacement des fonctions anonymes par des expressions λ. Quelques exemples :

Avec fonction anonyme Avec expression λ
function keepName(name) {
   return keepIf(name, function(bm) { return bm.Name; });
}
function keepName(name) {
   return keepIf(name, bm => bm.Name);
}
function keepCategory(cat) {
   return keepIf(cat, function(bm) { return bm.Category; });
}
function keepCategory(cat) {
   return keepIf(cat, bm => bm.Category);
}
function sortBy(att) {
   if (att === 'name') {
      return function(lst) {
         return sort(lst, function(a,b) {
            return cleanArgument(a.Name) < cleanArgument(b.Name);
         });
      }
   } else if (att === 'category') {
      return function(lst) {
         return sort(lst, function(a,b) {
            return cleanArgument(a.Category) < cleanArgument(b.Category);
         });
      }
   } else {
      throw "bad sort criterion";
   }
}
function sortBy(att) {
   if (att === 'name') {
      return lst => sort(lst, (a,b) => cleanArgument(a.Name) < cleanArgument(b.Name));
   } else if (att === 'category') {
      return lst => sort(lst, (a,b) => cleanArgument(a.Category) < cleanArgument(b.Category));
   } else {
      throw "bad sort criterion";
   }
}

L'allègement est visible.


Valid XHTML 1.0 Transitional

CSS Valide !