- Blog Xebia France - http://blog.xebia.fr -

REST côté serveur avec Java

Posted By Yves Amsellem On Lundi 14 novembre 2011 @ 7:00 In Architecture | 2 Comments

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é dans un article connexe — communiquant avec un serveur Java — présenté ici –. Le code clé-en-main est disponible sur GitHub.

JAX-RS — Java API for RESTful Web Services — standardise l’implémentation de REST en Java (une API + une servlet). Nous retiendrons son implémentation de référence, Jersey, et la déploierons sur un serveur Jetty-Embedded (le code clé-en-main dispose d’un main effectuant le déploiement ; pas besoin d’installer de serveur).

Les web services REST sont des ressources. Une ressource est identifiée par un nom du domaine, produit, commande, etc. HTTP définit 7 verbes pour manipuler les ressources :

  • GET pour la lecture,
  • PUT pour la modification,
  • DELETE pour la suppression,
  • POST pour la création et autre,
  • OPTIONS (verbes disponibles), HEAD (prise de pouls) et TRACE (écho des headers de l’appelant) non abordés

Une fois déployée, la servlet Jersey mappe l’url /resource/* ; la partie cliente est disponible sur /index.html. Le web.xml définit deux filtres Jersey afin de loguer les trames HTTP des échanges client-serveur.

Par convention, GET /product liste tous les produits, GET /product/12 affiche le produit identifié, PUT /product/12 remplace le produit identifié par celui transmit, DELETE /product/12 supprime le produit identifié, POST /product crée le produit transmis et fourni son identifiant en réponse. Les verbes GET, PUT et DELETE sont sûrs et idempotents (sans effet de bord – effet identique à requête identique). POST est utilisé comme factory (ici pour créer de nouveaux produits) et pour toute opération non sûre ou non idempotente (par exemple, déplacer une quantité du stock vers un panier). Les navigateurs internet utilisent abondamment GET pour accéder au contenu d’une page web et POST lors de la soumission d’un formulaire.

Ressources

L’application que nous nous apprêtons à développer 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, ProductResource et BasketResource, dont voici les définitions :

URI Verbes disponibles Effet
product GET, POST liste et création de produits
product/{id} GET, DELETE produit accessible, supprimable par id
product/{id}/stock GET, POST stock d’un produit, ajout de quantité
product/{id}/stock/{quantity}/{username} POST réservation client
basket/{username} GET, DELETE panier de username, suppression
basket/{username}/{productid} GET stock d’un produit du panier
basket/{username}/price GET prix du panier
basket/{username}/payment POST paiement du panier

Récupérer la liste des produits s’effectue avec un GET sur http://localhost:8080/resource/product.

À l’instar de la navigation sur internet, naviguer entre ressources s’effectue à l’aide de liens. Alors que sur le web c’est le nom du lien qui aiguille l’internaute, ici c’est la relation — l’attribut rel — qui permet de naviguer. HTTP définit des relations standards, pour couvrir les besoins de l’exercice, les suivantes lui sont ajoutées et communiquées au client :

Relation Ressource Effet
rels/book /product réservation d’une quantité d’un produit par un client
rels/price /basket accès au prix du panier
rels/payment /basket accès au paiement du panier
rels/related * standard, utilisé ici pour donner accès à un produit du panier client

Représentations

Les représentations sont les objets échangés entre client et serveur (en XML ou JSON, à la demande du client). Elles sont définies dans un XSD. Côté serveur, la grappe d’objets équivalente est générée par XJC. Un précédent article aborde les notions de JAXB en détails. Voici une définition compacte des représentations :

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

La servlet Jersey est initialisée avec le paramètre POJOMappingFeature afin d’utiliser Jackson pour le marshalling JSON plutôt que JAXB, dont la responsabilité est ici limitée au marshalling XML. Jackson produit un JSON plus standard — la dépendance jersey-json est ajoutée au pom.xml.

JAXB requiert l’annotation @XmlRootElement afin de produire/consommer les objets du modèle en XML. Jackson n’en nécessite aucune.

Les représentations et les ressources sont intimement liées. Leur nommage gagne à être cohérent : la ressource /product produit une List lors d’un GET et consomme un Product lors d’un POST ; la ressource /stock produit et consomme un Stock. Sans information (le déplacement d’un produit dans le panier et le paiement), aucune représentation n’est consommée ni produite, seuls l’URI, le statut de retour et les headers caractérisent l’échange.

Status de retour

Quelle que soit l’opération demandée, les ressource indiquent toujours un statut de retour. Ce code, composé de 3 chiffres dont le premier spécifie la catégorie (1. information, 2. succès, 3. redirection, 4. erreur client, 5. erreur serveur), est indiqué à chaque appel de ressource, quel que soit le verbe HTTP utilisé et la présence, ou non, de représentation. 200, 301 et 404 sont les plus connus, ils indiquent, dans l’ordre, un succès, une redirection et l’absence du document recherché. Se référer à la liste des statuts HTTP pour plus de détail.

Headers

Une trame HTTP est composée, en plus d’une représentation — le body — et d’un statut de retour, de headers — l’entête –. Les headers HTTP, une liste de propriétés clé/valeur, en plus de véhiculer la négociation de médiatype, communiquent des informations de cache, de concurrence et de sécurité notamment. Un précédent article aborde les notions d’authentification HTTP en détails.

Ressource produit

L’intégralité du code étant disponible sur GitHub, nous nous focaliserons sur les points clé de l’implémentation. Toute latitude est laissée au lecteur d’aller et venir entre les explications suivantes et le code lié. Afin de simplifier la compréhension, le rôle du référentiel de données est joué par des classes dotées de méthodes statiques, Products, Stocks et Purchases. Commençons avec la classe ProductResource.

import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
 
@Path("/product")
public class ProductResource {
    @GET
    @Path("/{id}")
    public Response get(@PathParam("id") long id) {
        Product product = Products.get(id);
        return Response.ok(product).build();
    }
 
    @POST
    public Response post(Product product) {
        Products.put(product);
        return Response.ok(product).build();
    }
 
    @GET
    public Response get() {
        List<Product> products = Products.get();
        GenericEntity<List<Product>> entity = new GenericEntity<List<Product>>(products) {};
        return Response.ok(entity).build();
    }
}

L’annotation @Path permet de déclarer l’URI d’une ressource au niveau d’une classe puis de la compléter si nécessaire au niveau méthode (le scannage de Jersey peut être restreint à un package dans le web.xml). Les méthodes HTTP sont représentées quant à elles par des annotations du même nom @GET, @PUT, @DELETE et @POST appartenant au package javax.ws.rs de JAX-RS. Du même package, le builder Response permet notamment de positionner le code de retour (la méthode ok(...) le positionne à 200), la représentation retournée, les headers, etc.

GenericEntity est nécessaire lors de la production d’une List en guise de réponse. Les implémentations de List n’étant pas annotés avec JAXB, cette encapsulation permet de positionner une liste au premier nœud du graphe.

En cas d’absence du produit, une NotFoundException est levée par Products. Cette exception, de la famille des WebApplicationException est capturée par un mapper annoté @Provider. Cette classe d’exceptions introduite par Jersey remonte une réponse avec statut de retour approprié : ici, 404.

La classe Product (générée par XJC) est la représentation consommée et produite par cette ressource ; elle a été réduite ici à sa plus simple expression.

import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
 
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class Product {
    protected long id;
    protected String name;
    protected int price;
    protected List<Link> links;
}

Lors d’un appel à cette ressource le header Accept permet d’indiquer les préférences du médiatype utilisé pour communiquer la représentation. Le navigateur Chrome, par exemple, indique les suivants Accept: text/html,application/xml;q=0.9,/;q=0.8. Ceux-ci sont pondérés, ainsi il préfère XML (0.9) à JSON (0.8, */* indiquant ceux non exprimés auparavant). Il obtient donc la représentation en XML :

<products>
  <product>
    <id>0</id>
    <name>pull</name>
    <price>25</price>
    <links>
      <href>resource/product/0/stock/{quantity}/{username}</href>
      <rel>rels/book</rel>
    </links>
  </product>
</products>

Appelé d’un client JavaScript la représentation sera bien plus compacte, grâce au JSON :

[{
  "id": 0,
  "name": "pull",
  "price": 25,
  "links": [{
    "href": "resource/product/0/stock/{quantity}/{username}",
    "rel": "RELS_BOOK"}]
}]

Ces représentations ont été indentées, sans quoi, elles seraient exprimées sur une simple ligne.

L’échange client serveur peut être simplifié ainsi :

# Request
GET /product HTTP/1.1
Host: localhost:8080
Accept: application/json;q=1.0
 
# Response
HTTP/1.1 200 OK
Content-Type: application/json
[{"id": 0...}]

Tests produit

Afin de s’assurer du fonctionnement de cette première ressource, un serveur Jetty-Embedded peut être déployé et une batterie de tests exécutée à l’aide du client Jersey. Voyons comment avec la classe ProductResourceTest et son référentiel de données Shipments.

import static com.sun.jersey.api.client.ClientResponse.Status.OK;
import static com.sun.jersey.api.client.ClientResponse.Status.NOT_FOUND;
 
import org.junit.Rule;
import javax.ws.rs.core.UriBuilder;
 
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientResponse;
 
public class ProductResourceTest {
  @Rule
  public EmbeddedTestServer server = new EmbeddedTestServer();
 
  @Before
  public void before() {
    for (Product product : Shipments.products())
      productResource().post(product);
  }
 
  @Test
  public void shouldGetProduct() {
    Product product = productResource(2).get(Product.class);
    assertNotNull(product);
    assertEquals(product.getName(), "L'étranger");
  }
 
  @Test
  public void shouldNotGetUnexistingProduct() {
    ClientResponse response = productResource(12).get(ClientResponse.class);
    assertEquals(response.getStatus(), NOT_FOUND.getStatusCode());
  }
 
  @Test
  public void shouldListProducts() {
    ClientResponse clientResponse = productResource().get(ClientResponse.class);
    List<Product> products = clientResponse.getEntity(new GenericType<List<Product>>() {});
 
    assertEquals(OK.getStatusCode(), clientResponse.getStatus());
    assertEquals(Shipments.size(), products.size());
  }
 
  /* helpers */
 
  private WebResource productResource() {
    URI uri = UriBuilder.fromPath("resource/product").build();
    return Client.create().resource(server.uri()).path(uri.getPath());
  }
 
  private WebResource productResource(long productId) {
    URI uri = UriBuilder.fromPath("resource/product/{id}").build(productId);
    return Client.create().resource(server.uri()).path(uri.getPath());
  }
}

L’annotation @Rule permet d’externaliser le lancement du serveur (basé sur le web.xml). L’initialisation réalisée par l’annotation @Before utilise POST comme une factory pour créer, un à un, de nouveaux produits. Les tests utilisent GET pour vérifier la présence de ceux-ci (et leur numérotation côté serveur). Les listes nécessitent l’utilisation de GenericType afin de conserver leur typage.

Le client a généré, au préalable, les représentations avec XJC. Ainsi, il peut demander l’unmarshalling de la réponse à Jersey. On notera l’accès direct à la classe Product dans le premier test et le passage préalable par ClientResponse dans le second, donnant accès, en plus de la représentation de la réponse, à son statut et ses headers. On notera également la gestion de template proposée par UriBuilder dont la méthode build(...) remplace les différents tags.

Ressource stock

La notion de stock d’un produit lui est indépendante. Positionner le stock comme attribut de produit porte à confusion : déplacer un produit du stock vers le panier client ne doit pas concerner tout le stock, seulement la quantité réservée. Pour autant, définir une ressource indépendante /stock/{productid} est ambiguë. Une manière plus élégante est de l’exprimer comme noeud du produit /product/{id}/stock ; cela traduit bien l’appartenance de l’un à l’autre sans impliquer la présence d’un attribut dans la représentation. Les / d’une URL définissent toujours une hiérarchie.

L’implémentation suivante considère l’ajout au stock comme un arrivage et utilise donc POST. Si l’accès au stock avait été considéré comme le remplacement de la quantité existante, PUT aurait été retenu.

import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.EntityTag;
import com.google.common.base.Objects;
 
@Path("/product")
public class ProductResource {
  @POST
  @Path("/{id}/stock")
  public Response addToStock(@PathParam("id") long id, Stock stock) {
    Integer instock = Stocks.quantity(id);
    Stocks.put(id, instock + stock.getQuantity());
    return Response.status(Status.ACCEPTED).build();
  }
 
  @GET
  @Path("/{id}/stock")
  public Response stock(@PathParam("id") long id) {
    Stock stock = new Stock();
    stock.setQuantity(Stocks.quantity(id));
    return Response.ok(stock).tag(eTag(id, stock.getQuantity())).build();
  }
 
  private EntityTag eTag(long id, int quantity) {
    return new EntityTag(String.valueOf(Objects.hashCode(id, quantity)));
  }
}

L’ajout de stock vérifie au préalable l’existence du produit et renseigne le statut de la réponse en conséquence (Stocks s’en remet à Products qui lève une NotFoundException le cas échéant). La classe-représentation consommée et produite par cette ressource est la suivante.

@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class Stock {
  protected int quantity;
  protected long id;
  protected Link related;
}

Lors de l’accès à la ressource stock un tag d’accès concurrent est ajouté aux headers. Ce dernier est calculé à partir du hash de l’id produit et de la quantité disponible. Lorsqu’il veut réserver une certaine quantité du stock, le client fournit ce tag — qu’il estime être le dernier en date — afin que le serveur puisse lui indiquer si la ressource a changé depuis (ici, si la quantité a changé). Si c’est le cas, le client doit la récupérer à nouveau du serveur. Lorsque les deux tags correspondent, le serveur accepte la demande client.

Ce tag peut également permettre 1. de ne pas reconstruire une grappe d’objets côté serveur si le client a déjà sa dernière version 2. de gérer la concurrence de modification d’une ressource.

import javax.ws.rs.core.Request;
import javax.ws.rs.core.Context;
 
@Path("/product")
public class ProductResource {
  @POST
  @Path("/{id}/stock/{quantity}/{username}")
  public Response post(@PathParam("id") long id, @PathParam("quantity") int quantity,
      @PathParam("username") String username, @Context Request request) {
 
    String message;
    int productQuantity = Stocks.quantity(id);
 
    if (request.evaluatePreconditions(eTag(id, productQuantity)) == null) {
      if (Stocks.sell(id, quantity)) {
        Purchases.put(username, id, quantity);
        return Response.ok().build();
 
      } else message = "Product is out of stock";
    } else message = "eTag mismatch";
    return Response.status(Status.PRECONDITION_FAILED).entity(message).build();
  }
}

Lors de la réservation d’un produit, le tag transmis est comparé (par le biais d’une méthode utilitaire de Request) à sa valeur actuelle. Si ce calcul est couteux, la valeur du hash peut être sauvegardée avec l’objet afin d’économiser ce coût. Cette comparaison renvoie un statut 412 si un problème se produit. Ensuite, le stock est décrémenté s’il lui reste assez d’articles (la méthode sell renvoie un booléen acquittant l’opération).

Lorsqu’une erreur se produit, la réponse, en plus du code 412 — préconditions non respectées –, se voit dotée d’un message d’erreur approprié.

Afin de limiter le couplage, le lien de réservation ne sera pas connu par le client. Ce dernier sera averti de l’accès à la réservation par la présence d’une relation rels/book. Cette relation est ajoutée au produit lors de sa création via POST.

@Path("/product")
public class ProductResource {
  @POST
  public Response post(Product product) {
    Products.put(product);
    addBookLink(product);
    return Response.ok(product).build();
  }
 
  static UriBuilder uriBuilder = //
  UriBuilder.fromPath("resource/product").path("{id}/stock/{quantity}/{username}");
 
  private void addBookLink(Product product) {
    Link link = new Link();
    URI uri = uriBuilder.build(product.getId(), "{quantity}", "{username}");
    link.setHref(uri.getPath());
    link.setRel(Rels.RELS_BOOK);
    product.getLinks().add(link);
  }
}

Enfin, une fois une quantité du stock déplacée vers un panier client, une ressource va permettre l’accès au panier d’un client. N’ayant que peu de sens sans le nom du client, ce paramètre est rendu général à la classe.

@Path("/basket/{username}")
public class BasketResource {
  @PathParam("username") String username;
 
  @GET
  @Path("/{product}")
  public Response get(@PathParam("product") long productId) {
    Map<Long, Integer> quantityByProductId = Purchases.get(username);
    Stock stock = new Stock();
    stock.setId(productId);
    stock.setQuantity(quantityByProductId.get(productId));
    return Response.ok(stock).build();
  }
}

Tests stock

Pour incrémenter le stock d’un produit, son id est nécessaire ; mais, comme il est généré côté serveur, il est nécessaire de le récupérer lors de sa création. Par convention, POST retourne toujours l’objet qui lui est soumis décoré au minimum d’un identifiant. La création d’un produit, présentée en tête de cet article, s’appuie sur la classe utilitaire Products associant un id au produit avant de retourner le produit ainsi identifié au client. Voici le cas de test du stock.

public class ProductResourceTest {
  @Before
  public void before() {
    for (Product product : Shipments.products()) {
      product = productResource().entity(product).post(Product.class);
      Stock stock = new Stock();
      stock.setQuantity(2);
      stockResource(product.getId()).post(stock);
    }
  }
 
  private WebResource stockResource(long productId) {
    URI uri = UriBuilder.fromPath("resource/product/{id}/stock").build(productId);
    return Client.create().resource(server.uri()).path(uri.getPath());
  }
}

Ce code reprend l’initialisation précédente et ajoute, pour chaque produit, un stock de 2 unités. Les produits ayant désormais du stock il est possible de tester leur réservation dans le panier d’un client.

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.xebia.representation.Rels;
 
public class ProductResourceTest {
  @Test
  public void shouldAccessStock() {
    for (Product product : getProducts())
      assertEquals(2, stockResource(product.getId()).get(Stock.class).getQuantity());
  }
 
  @Test
  public void shouldMoveProductToBasket() {
    String username = "xebia";
    int quantity = 2;
 
    Product product = getProducts().get(0);
    String uriBook = rels(product.getLinks()).get(Rels.RELS_BOOK);
    resource(uriBook, ImmutableMap.of("quantity", quantity, "username", username)).post();
 
    assertEquals(0, stockResource(product.getId()).get(Stock.class).getQuantity());
    assertEquals(quantity, basketResource(username, product.getId()).get(Stock.class).getQuantity());
  }
 
  /* helpers */
 
  private WebResource resource(String href, Map<String, ?> params) {
    URI uri = UriBuilder.fromPath(href).buildFromMap(params);
    return resource(uri.getPath());
  }
 
  private List<Product> getProducts() {
    ClientResponse clientResponse = productResource().get(ClientResponse.class);
    return clientResponse.getEntity(new GenericType<List<Product>>() {});
  }
 
  private Map<Rels, String> rels(List<Link> links) {
    Map<Rels, String> rels = Maps.newHashMap();
    for (Link link : links)
      rels.put(link.getRel(), link.getHref());
    return rels;
  }
 
  private WebResource basketResource(String username, long productId) {
    URI uri = UriBuilder.fromPath("resource/basket/{user}/{id}").build(username, productId);
    return createClient().resource(server.uri()).path(uri.getPath());
  }
}

On notera l’utilisation du lien de réservation, rels/book, proposé par la ressource stock limitant la connaissance du client à la template (quantity, username) qu’il doit poster. En l’absence d’un article par exemple, ce lien peut disparaitre, mettant le client — ignorant l’URI complète — dans l’impossibilité d’effectuer une réservation. Ainsi l’état de l’application et ses possibilités futures sont exprimées par ces relations hypermédia (HATEOAS). Client et serveur partagent la connaissance des transitions, celles-ci étant rendues disponibles par le serveur lors de l’accès aux ressources. Le couplage obtenu entre client et serveur en est ainsi affaibli.

Le panier propose des relations afin d’accéder à chaque article réservé (la relation related est souvent utilisé à des fins d’économie de ce genre) au prix total et au paiement. Un client, après avoir accédé à une ressource, navigue grâce aux relations communiquées par cette dernière aux ressources suivantes. Le code clé-en-main disponible sur GitHub couvre le paiement et la rupture du stock, afin de prolonger le sujet.

Épilogue

Implémenter REST avec Jersey résulte en un code compact, élégant, explicite et indépendant du médiatype. Le cache, la sécurité et la concurrence sont supportés dans le même esprit de simplicité. Quelques considérations sur le sujet ; comme le nom d’un auteur littéraire à succès, les URI publiques gagnent à ne pas changer. Divulger le minimum d’URI, en offrant un jeu de relations riches au client, masque la complexité du serveur ; les clients agissant en fonction de relations, modifier la logique du serveur est alors peu impactant et réduit le recours au versionning.

L’implémentation d’un client JavaScript communiquant avec le serveur développé ici, réalisée dans l’article connexe consacré au sujet, est l’occasion d’apprécier davantage les vertus de l’interface uniforme de HTTP. Le livre RESTful Web Services Cookbook est un excellent moyen de se plonger davantage encore sur la question.


Article printed from Blog Xebia France: http://blog.xebia.fr

URL to article: http://blog.xebia.fr/2011/11/14/rest-java-serveur/