Publié par
Il y a 7 années · 13 minutes · Architecture

REST côté client avec JavaScript

Voilà 11 ans que Roy Fielding a introduit REST, le style d’architecture original du web appliqué aux échanges inter-applications. Reposant sur HTTP, il promet économie, simplicité et profit des structures réseau en place. Voyons comment l’implémenter via un client JavaScript — présenté ici — communiquant avec un serveur Java — présenté dans un article connexe –. Le code clé-en-main est disponible sur GitHub.

Le client JavaScript que nous allons déployer repose sur jQuery — un framework de haute productivité —, nous lui adjoindrons BackboneJS, un framework MVC REST, et RequireJS, un chargeur de modules à la demande.

Ressources et représentations

Le serveur auquel se connecte notre client est une boutique en ligne type Amazon. Elle propose une liste de produits, disponibles en quantité limitée, qu’il est possible de réserver dans un panier client. Deux ressources implémentent ces fonctionnalités :

URI Verbes disponibles Effet
product GET, POST liste et création de produits
basket/{username} GET, DELETE panier de username, suppression

Ces ressources produisent et consomment les représentations suivantes :

product {id:long, name:string, price:int, links:link[]}
stock {quantity:int, id:long, related:link}
basket {stock:stock, links:link[]}
link {href:anyURI, rel:[rels/book,rels/price,rels/payment,rels/related]}

Les produits disponibles dans le stock disposeront, via leur attribut links, d’une relation afin de les ajouter au panier :

Relation Ressource Effet
rels/book /product réservation d’une quantité d’un produit par un client

Se reporter à l’article connexe pour plus de détails sur les notions de ressource, représentation et relation.

Structure du site

Notre client web est composé d’une simple page html, d’une css et de modules JavaScript. Outre ses headers, la page index.html se résume aux lignes suivantes.

<body>
  <div id="cart"></div>
  <div id="products">
    <div id="product-creation"></div>
    <div id="product-list"></div>
  </div>
</body>

Trois emplacements sont prévus dans cette page sous la forme de div ; un pour le panier client, un autre pour ajouter un produit en magasin, un dernier pour lister les produits existants. Vides lors de l’affichage de la page, ces trois emplacements vont être renseignés par les données serveur via jQuery. Ce dernier permet de manipuler dynamiquement le DOM – le contenu de la page – afin d’offrir une expérience d’utilisation sans rafraîchissement.

La mise en page est simpliste : les produits occupent la majorité de la page, le panier, lui, est confiné dans une « colonne » sur la droite.

#products {margin-right:250px;}
#cart {float:right; width:250px }

Modules externes

Si jQuery fournit d’excellents outils pour manipuler le DOM, il est agnostique quant à la structuration du code ; BackboneJS permet de le structurer en MVC et RequireJS de le découper en modules. Commençons par confier la gestion des dépendances à RequireJS dans le head de la page index.html.

<script
  data-main="js/main"
  src="//ajax.cdnjs.com/ajax/libs/require.js/0.24.0/require.min.js">
</script>

RequireJS, situé sur un CDN (un repository de librairies), est chargé du code JavaScript de notre page. Pour se faire, il a besoin du point d’entrée de l’application, que l’on nommera main.js. Dans ce fichier, on récupère la main via une méthode require. Les bibliothèques externes sont importées – le préfixe order! indique qu’elles seront chargées dans l’ordre de déclaration – puis notre code exécuté.

require({ baseUrl:'/js' },
  ["order!//ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js",
   "order!//ajax.googleapis.com/ajax/libs/jqueryui/1.8.13/jquery-ui.min.js",
   "order!//ajax.cdnjs.com/ajax/libs/underscore.js/1.1.6/underscore-min.js",
   "order!//ajax.cdnjs.com/ajax/libs/backbone.js/0.3.3/backbone-min.js",
   "order!/lib/jquery.pubsub.min.js"],
  function()
  {
      // application
  }
);

Une fois les librairies externes importées, les classes de l’application doivent l’être également. RequireJS permet de moduler une application à loisir, nous retiendrons un découpage « une classe par fichier », de nombreux autres sont imaginables. Il est important de noter que ce découpage est un artifice destiné à faciliter le développement, lors de la mise en production les sources RequireJS seront minifiées (compressées et rassemblées au sein d’un même fichier).

require(...,
  function () {
    require(["product/ProductView",
             "product/ProductCollection"],
    function (ProductView,
              ProductCollection) {
      // application
    });
  }
);

Les classes de l’application sont déclarées avec leur chemin et manipulées grâce à un alias. Elles doivent être déclarées dans un fichier distinct comme module RequireJS via une méthode define comme nous allons le voir.

Créer un produit avec POST

Maintenant que la structure du projet est définie, il est temps de connecter le code aux ressources serveur : BackboneJS entre en jeu. Le formulaire de création de produit est le premier élément à ajouter à la page ; en voici le contenu.

<form method="post" onsubmit="return false" class="product">
  <fieldset>
    <ul>
      <li><label>Name: <input type="text" id="name" size="3" /></label></li>
      <li><label>Price: <input type="text" id="price" size="3" /></label></li>
      <li><label>Stock: <input type="text" id="quantity" size="3" /></label></li>
    </ul>
    <input type="submit" value="new product" />
  </fieldset>
</form>

Dans un premier temps, la classe ProductCreationView doit écraser l’emplacement product-creation de index.html avec la template précédente.

La fichier ProductCreationView est déclaré comme module RequireJS par la méthode define qui le nomme et indique sa dépendance à la template (le préfixe text! permet l’accès à un fichier plat). Ce module retourne une classe View héritée de BackboneJS qui sera instanciée par l’appelant.

Une vue BackboneJS définit :

  • l’élément du DOM qu’elle manipule (avec jQuery) via la propriété el
  • son constructeur via la propriété initialize
  • les évènements capturés sur l’élément manipulé via la propriété events
define("ProductCreationView",
  ['text!/template/ProductCreationTemplate.html'],
  function(tmpl){
  return Backbone.View.extend({
    el: $('#product-creation'),
    initialize: function(){
      this.el.html(tmpl);
    }
  });
});

Le $ est un alias jQuery. Il donne accès à toutes les méthodes du framework ; avec une chaîne en argument, il joue le rôle de sélecteur. Il identifie un élément du DOM et permet de remplacer son contenu avec el.html(value). Le #, issu du monde css, représente l’attribut id d’une balise html (l’attribut class est représenté par un point, « . »).

Lorsqu’une instance de cette vue est créée, son constructeur écrase l’élément DOM à l’id product-creation et le remplace par le contenu de la template.

define("ProductCreationView",
  function(...){
    events: {
      "click input:submit": "create"
    },
    create: function() {
      var name = $('#name');
      var price = $('#price');
      var quantity = $('#quantity');
 
      this.collection.create({
        name: name.val(),
        price: price.val()
      });
    }
  });
});
 
define("ProductCollection", [], function(){
  return Backbone.Collection.extend({
     url: '/resource/product'
  });
});

Ensuite, lors du clic de l’utilisateur sur l’input submit de la template, la méthode create capture la valeur des inputs text Name, Price et Stock et délègue la création du produit à une collection (héritée de la Collection BackeboneJS). Cette dernière déclenche un POST sur la ressource /product avec la représentation qui lui est transmise.

Le fichier main.js déclare les deux classes ProductCollection et ProductCreationView, les instancie, et communique la collection à la vue afin qu’elle puisse créer un nouvel élément dans celle-ci (BackboneJS ne dispose pas de contrôleur à hériter). La méthode start est le bootstrap de l’application RequireJS.

require(...,
  function () {
    require(["product/ProductView",
             "product/ProductCollection"],
    function (ProductView,
              ProductCollection) {
      return {
      start: function() {
        var products = new ProductCollection();
        new ProductCreationView({ collection: products });
      }
    }
  }
);

Récapitulons :

  1. la page index.html est chargée par le navigateur
  2. la balise script initialise RequireJS qui donne la main à notre code
  3. une collection est instanciée avec une référence à la ressource /product
  4. une vue est instanciée avec cette collection
  5. cette vue charge la template de création de produit dans index.html
  6. lors d’une création de produit par l’utilisateur, la vue appelle create sur la collection de produits
  7. la collection de produits POST le produit au serveur et récupère celui retourné pour mettre à jour id et links

L’échange HTTP obtenu est le suivant :

# Request
POST /resource/product HTTP/1.1
Host: localhost:8080
Content-Type: application/json;q=1.0
{
  "name":"pull",
  "price":25
}
 
# Response
HTTP/1.1 201 CREATED
Content-Type: application/json
{
  "id":0,
  "name":"pull",
  "price":25,
  "links":[{
    "href":"resource/product/0/stock/{quantity}/{username}",
    "rel":"RELS_BOOK"
  }]
}

Lister les produits avec GET

Maintenant qu’il est possible d’ajouter des produits via l’interface, voyons comment les afficher ; un emplacement est prévu à cet effet, celui à l’identifiant product-list.

<form method="post" onsubmit="return false">
  <fieldset>
    <ul>
      <li><strong><%= product.get('name') %></strong></li>
      <li>Price: $<%= product.get('price') %></li>
      <li>
        <label>
          Qty: <input type="text" data-id="<%= product.get('id') %>" value="1" size="3"/>
        </label>
      </li>
    </ul>
 
    <input type="submit" data-id="<%= product.get('id') %>" value="add to cart" />
  </fieldset>
</form>

La template précédente permet l’affichage d’un produit, elle utilise la syntaxe UnderscoreJS <%= value %> (utilisé également par BackboneJS ; il existe de nombreux moteurs de templates, MustacheJS, entre autres). Elle est intégrée à la page grâce au code de la classe ProductView qui lui transmet un produit en paramètre. Le produit étant issu d’une collection BackboneJS, accéder à une propriété passe par un getter.

define("ProductView",
  ['text!/template/ProductTemplate.html'],
  function(tmpl){
  return Backbone.View.extend({
    el: $("#product-list"),
    initialize: function(){
      _.bindAll(this, 'render');
      this.collection.bind('add', this.render);
    },
    render: function() {
      var template = '';
      this.collection.each(function(product) {
        template += _.template(tmpl, { product: product });
      });
      this.el.html(template);
    }
  });
});

Le constructeur de cette classe positionne un écouteur sur l’ajout d’élément à la collection de produits avec bind. L’occurrence de cet évènement déclenche la propriété render de la vue. Cette écoute étant effectuée par la collection, le this manipulé par la méthode render de la vue lui est relatif. L’utilisation d’une méthode utilitaire d’Underscore, bindAll, permet de modifier ce comportement en indiquant que le this manipulé par la méthode render est la vue elle-même.

L’ajout d’un produit à la collection manipulée par cette vue ajoute une template pour chaque produit à la page. Les collections BackboneJS offrent une méthode each pour faciliter l’itération sur leurs éléments. Lorsque plusieurs éléments sont ajoutés à une page, il est plus performant de les concaténer et de les afficher d’un coup.

L’identifiant du produit est positionné sur l’input submit afin de localiser le produit sélectionné par le client par la suite. L’input text recevant la quantité réservée le reçoit également à cette fin.

Réserver une quantité d’un produit

Afin de permettre la réservation d’une certaine quantité d’un article, il est nécessaire d’ajouter le code suivant à la classe ProductView :

define("ProductView",
  function(...){
  return Backbone.View.extend({
    events: {
      "click input:submit": "book"
    },
    book: function(event) {
      var product_id = $(event.target).data('id');
      var quantity = $('input[type=text][data-id="'+product_id+'"]').val();
 
      var links = this.collection.get(product_id).get('links');
 
      var map = new Object();
      _.each(links, function(link) {
        map[link.rel] = link.href;
      });
 
      $.ajax({
        type: 'POST',
        url: map['RELS_BOOK'].replace('{quantity}', quantity).replace('{username}', "xebia"),
        success: function() {$.publish('basket-event')},
        error: function(xhr) { alert(xhr.responseText) }
      });
    }
  });
});

Le clic sur l’input submit de la vue est capturé et son évènement déclencheur permet d’identifier quel produit, parmi la liste proposée, a été sélectionné : event.target permet d’accéder à l’input submit qui a reçu le clic. Il est alors possible de récupérer ses attributs, ici son data-id (cet identifiant est préférable à id car il est utilisé pour deux éléments, la quantité et le bouton submit). Les collections BackboneJS offrent une méthode get(id) permettant d’accéder à un élément par son identifiant. La quantité est récupérée à l’aide d’un sélecteur jQuery.

Les relations proposées par le produit sélectionné sont copiées dans une map, et la template de réservation d’un produit est complétée (quantity et username). Un POST est réalisé sur le lien obtenu.

Afficher le panier

Lors du succès d’une réservation, le panier doit être rafraîchi. La ressource /product ajoute un ou plusieurs produits dans le panier utilisateur, représenté par la ressource /basket. La ressource /basket ne remonte donc aucune notification au client, puisqu’elle est modifiée côté serveur. Il donc nécessaire, comme le fait le handler success de l’appel de réservation, de publier un évènement lors du succès d’un achat à destination du panier afin qu’il se rafraîchisse. Cette gestion d’évènements est ajoutée à jQuery par Tiny Pub/Sub.

define("BasketView",
  function(...) {
  return Backbone.View.extend({
    el: $('#cart'),
 
    var view = this;
    $.subscribe('basket-event', view.fetch);
  },
  fetch: function() {
    var view = this;
    this.model.clear();
    this.model.fetch({ success: view.render });
  },
  price:0,
  render: function() {
    var stocks = this.model.get('stock');
    var view = this;
    var tmpl = '';
    var total = 0;
 
    _.each(stocks, function(stock) {
        var product = view.collection.get(stock.id);
        var price = stock.quantity * product.get('price');
        total += price;
        tmpl += _.template(basketItemTemplate, 
                          { quantity: stock.quantity, 
                            name: product.get('name'), 
                            price: price });
    });
    this.el.html(_.template(basketTemplate, { total: total }));
    $('tbody', this.el).html(tmpl);
    this.price = total;
  });
});

Deux templates sont nécessaires à l’affichage du panier : un tableau et un élément répété pour chaque produit. Le calcul du prix du panier est effectué côté client ; lors d’un clic utilisateur sur le bouton « checkout » le prix est comparé à celui retourné par le serveur et, s’il est correct, le payement est effectué et le panier vidé (la comparaison du prix et son calcul côté client sont, bien entendu, réalisés à des fins didactiques).

define("BasketView",
  function(...) {
  return Backbone.View.extend({
    events: {
      "click input:submit": "checkout"
    },
    checkout: function() {
      var links = this.model.get('links');
      var map = {};
 
      _.each(links, function(link) {
        map[link.rel] = link.href;
      });
 
      var total = this.price;
      $.getJSON(map['RELS_PRICE'], function(response) {
        if(response.value == total)
          $.post(map['RELS_PAYMENT'], function() { $.publish('basket-event') });
      });
    }
  });
});

Épilogue

L’interface unifiée de HTTP offerte par BackboneJS simplifie grandement l’interaction avec un serveur REST (en plus des exemples présentés ici, la modification d’un élément déclenche un PUT, sa suppression un DELETE). L’utilisation d’une liste de relations, plutôt que d’URI, entre client et serveur diminue encore leur couplage. Pour approfondir le sujet davantage, se référer à l’article de référence sur l’intégration des solutions jQuery dans l’entreprise.

Yves Amsellem
Développeur depuis 5 ans — les 2 derniers chez Xebia — Yves tire de son expérience sur des sites à fort trafic une culture de la qualité, de l'effort commun et de l'innovation. Spécialisé du style d'architecture ReST, il intervient sur des projets web à forte composante JavaScript et NoSQL. Avec Benoît Guérout, il développe la librairie open source Jongo — Query in Java as in Mongo shell

5 thoughts on “REST côté client avec JavaScript”

  1. Publié par Harold Capitaine, Il y a 7 années

    Merci pour cet article qui présente bien la simplicité de mise en place de REST côté client grâce à certaines librairies JS.
    Pour ma part je proposerai des libraires encore plus simple mais avec la même puissance. Toujours Jquery bien sûr, require.js est très bien et peut aussi être remplacer par lab.js (Question de goût et de couleur). Par contre au niveau du template j’utiliserai la libraire tempo.js beaucoup plus simple à utiliser que ne l’est underscore.

  2. Publié par phil le chercheur, Il y a 7 années

    Très bon tuto qui tombe à pique !

  3. Publié par yobo, Il y a 6 années

    Yves, peux-tu nous parler des solutions de contournement quand un serveur web refuse l’usage des verbes PUT/DELETE ? Merci ;-)

  4. Publié par Yves Amsellem, Il y a 6 années

    @yobo en l’absence de PUT/DELETE j’utiliserais POST doté d’un paramètre indiquant POST/PUT/DELETE et conserverais GET pour les accès en lecture seule ; mais il y a plusieurs écoles à ce sujet. Ce paramètre peut être positionné dans l’url (ce que je décommande) ou dans le body du POST.

  5. Publié par Alexis Kinsella, Il y a 6 années

    Bonjour,

    Backbone comme d’autres frameworks Javascript proposent le support du verbe POST associé à un en-tête HTTP pour pallier à ce genre de problèmes. L’option est Backbone.emulateHTTP et l’en-tête est: X-HTTP-Method-Override.

    Regarde ce qui est fait dans backbone.js ligne 53 et dans la méthode Backbone.sync: https://github.com/documentcloud/backbone/blob/master/backbone.js

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *