An english version of this post is available here.

UPDATE 2012/12/23: Correction d'une erreur d'étourderie sur le premier bloc ligne 4, l'objet ResponseBuilder n'a pas de méthode ok().

Ah les joies d'AJAX et du Cross-Domain ... Ou plutôt le cauchemar des développeurs. Aujourd'hui, je vais vous présenter un concept pour rendre rapidement et simplement une API REST Java/Jersey compatible avec la norme W3C CORS pour faire du Cross-Domain sans utiliser JSONP.

Contexte

La plupart du temps, quand un développeur doit faire des requêtes Cross-Domain, il n'a pas d'autres choix que d'utiliser du JSONP : un callback est injecté dans une réponse en JSON puis le résultat est directement exécuté en Javascript. Cette méthode est lourde et relativement sale (et sur le coup j'avoue ne pas être assez sale pour l'utiliser).

Dans le cadre d'une mission où nous devions réaliser une API REST, j'ai décidé de trouver une autre solution que de faire du JSONP. La réponse se trouve dans la dernière révision de la norme Cross-Origin Resource Sharing (CORS) du W3C.

Principe

Cette dernière révision nous présente l'ajout d'en-têtes spéciaux (qui ne font pas partie de la RFC 2616) et de la preflight request exécutée par le navigateur avant d'envoyer sa requête AJAX pour vérifier les permissions d'accès.

Côté navigateur

  • Origin: indique le domaine de la requête
  • Access-Control-Request-Method: indique la méthode utilisée dans la requête
  • Access-Control-Request-Headers: indique l'en-tête qui sera utilisé par le navigateur et devra être autorisé par le serveur pour terminer la requête (utilisé lors d'une requête preflight)

Côté serveur

  • Access-Control-Allow-Origin: indique le(s) domaine(s) autorisé(s) à faire des requêtes Cross-Domain (doit contenir au minimum le résultat de Origin ou *)
  • Access-Control-Allow-Credentials: indique si l'utilisation de credentials est autorisée lors d'une requête Cross-Domain (Cookie, HTTP Authentication, ...)
  • Access-Control-Expose-Headers: indique les en-têtes pouvant être exposés sans risque à un navigateur
  • Access-Control-Max-Age: indique le temps dont une réponse à une preflight request peut être mise en cache par le navigateur
  • Access-Control-Allow-Methods: indique la liste des verbes HTTP pouvant être utilisés pour une requête Cross-Domain (doit contenir au minimum le résultat de Access-Control-Request-Method)
  • Access-Control-Allow-Headers: indique la liste des en-têtes personnalisés autorisés pour une requête Cross-Domain (doit contenir au minimum le résultat de Access-Control-Request-Headers)

Dans ce billet, je mets de côté Access-Control-Allow-Credentials, Access-Control-Expose-Headers et Access-Control-Max-Age.

Dans les faits

En condition normale, le navigateur va ajouter les en-têtes Origin et Access-Control-Request-Method lors de la requête. Une requête préliminaire sera faite (preflight request) si des en-têtes personnalisés sont présents, si la requête utilise une méthode autre que GET et POST ou encore que le client envoie des données qui ne sont pas au format text/plain (du JSON par exemple).

Voici un exemple de preflight request envoyée par Firefox :

OPTIONS /monurl HTTP/1.1
Host: 127.0.0.1:5555
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 115
Connection: keep-alive
Origin: http://127.0.0.1
Access-Control-Request-Method: POST
Access-Control-Request-Headers: x-requested-with

On remarque bien les en-têtes Origin, Access-Control-Request-Method et Access-Control-Request-Headers. Maintenant, Firefox s'attend à recevoir une réponse de ce style de la part du serveur (exemple) :

X-Powered-By: Servlet/3.0
Server: GlassFish Server Open Source Edition 3.0.1
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: x-requested-with

A ce niveau, Firefox sait qu'il peut faire des requêtes AJAX vers ce serveur, il continue donc avec ses requêtes normales en ajoutant son en-tête personnalisé :

X-Requested-With: XMLHttpRequest

Et notre API ?

J'y viens, côté code maintenant nous considérons avoir une API REST déjà faite (en utilisant Jersey). Il suffit d'ajouter une fonction similaire à celle-là :

private String _corsHeaders;

private Response makeCORS(ResponseBuilder req, String returnMethod) {
   ResponseBuilder rb = req.header("Access-Control-Allow-Origin", "*")
      .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");

   if (!"".equals(returnMethod)) {
      rb.header("Access-Control-Allow-Headers", returnMethod);
   }

   return rb.build();
}

private Response makeCORS(ResponseBuilder req) {
   return makeCORS(req, _corsHeaders);
}

Puis d'ajouter autant de fonctions comme celle-ci que de @Path à gérer (je n'ai pas réussi à trouver le @Path "catch-all") :

   // La méthode OPTIONS doit être gérée si vous faites des requêtes
   // avec autre chose que du GET, POST ou que le client transmet
   // des données dans un format différent de text/plain
   @OPTIONS
   @Path("/maressource")
   public Response corsMaRessource(@HeaderParam("Access-Control-Request-Headers") String requestH) {
      _corsHeaders = requestH;
      return makeCORS(Response.ok(), requestH);
   }

   @GET
   @Path("/maressource")
   public Response maRessource() {
      // Traitement de la requête, ResponseBuilder maReponse
      return makeCORS(maReponse);
   }

Bien entendu, ceci n'est donné qu'à titre d'exemple et libre à vous d'adapter. Entre autre renseigner dynamiquement Access-Control-Allow-Methods en fonction de l'API et de son WADL ou encore restreindre Access-Control-Allow-Origin.

Et la compatibilité dans tout ça ?

Point de vue compatibilité avec cette petite norme laissez tomber Internet Explorer 6 et 7, quant à Internet Explorer 8 on est sauvé par l'ajout d'un objet spécial XDomainRequest remplaçant XMLHttpRequest. A noter cependant que l'objet XDomainRequest ne semble pas être compatible avec les preflight requests. Pour les autres navigateurs ça tourne globalement partout avec les dernières versions.

Plus d'informations :

Enjoy it!