Unfiltered - Implémenter des services REST en Scala


La définition de REST trouve toute son essence à travers le protocole HTTP. Pourtant, la plupart des frameworks actuels reposent sur des API Java encore trop éloignées de ce dernier. La spécification de la JAX-RS (Java API for RESTful Web Services) a permis de simplifier la création de services REST au sein des applications JEE, entre autre, via l’utilisation des annotations. Cependant, ces implémentations gardent le plus souvent un accès direct à l’API Servlet rendant ainsi les applications web dépendantes du serveur d’applications sur lequel elles sont déployées.

Unfiltered (http://unfiltered.databinder.net) est un “micro framework” web qui permet ni plus ni moins l’intégration de services REST en Scala. Il est développé en partie par Nathan Hamblen. Unfiltered définit, entre autre, un niveau d’abstraction élevé pour le traitement des requêtes et des réponses, de manière à pouvoir exécuter toutes applications, utilisant la librairie “core”, sur une variété de serveurs, tel que Tomcat ou encore Netty.

La librairie se présente comme une simple couche de transition entre HTTP et Scala. Comme beaucoup d’autres frameworks, elle adopte pleinement certains concepts du langage comme pierre angulaire de son API. Pour cela, elle offre une approche élégante pour router les requêtes HTTP entrantes à travers l’utilisation du pattern matching.

Un premier exemple

Pour vous présenter les différents éléments de l’API et le vocabulaire utilisé au sein de celle-ci, voici un premier exemple.

object SayHello {
  val intent = unfiltered.Cycle.Intent[Any, Any] {
    case req@Path("/hello") => req match {
      case POST(_) & Params(params) =>
	Ok ~> ResponseString("Hello " + params("name")(0).toString)
      case _ => MethodNotAllowed ~> ResponseString("Method must be POST")
    }
    case req@Path(Seg("hello" :: name :: Nil)) => req match {
      case GET(_) => 
	Ok ~> ResponseString("Hello " + name)  
      case _ => MethodNotAllowed ~> ResponseString("Method must be GET")
    }
  }
}

Intents

Tout d’abord, dans notre exemple nous avons commencé par implémenter une fonction appelée Intent et dont la spécification est la suivante:

type ResponseFunction[B] = HttpResponse[B] => HttpResponse[B]
type Intent[T] = PartialFunction[HttpRequest[T], ResponseFunction]

Cette fonction représente l’interface principale de l’API avec pour objectif de matcher une requête et de retourner une fonction permettant de produire la réponse correspondante.

Dans le cas présent, notre Intent matche les deux requêtes ci-dessous:

  • POST /hello
  • GET /hello/{name}

Puis, elle retourne en réponse la chaîne de caractères “Hello “ suivi du paramètre ‘name’. Par ailleurs, si une des URI est appelée avec une méthode HTTP qui n’est pas supportée, une erreur est alors retournée.

Plans

La définition d’une Intent n’est pas directement liée à une interface serveur particulière. De ce fait, Unfiltered définit un ensemble de traits dont le rôle est de lier une Intent à une interface serveur spécifique. Ces traits sont désignés sous le nom de Plan. Par exemple, le trait unfiltered.filter.Plan est une implémentation de l’interface javax.servlet.Filter qui délègue le traitement de la requête à sa méthode ‘intent’.

object SayHelloFilter extends
	unfiltered.filter.Planify(SayHello.intent)

De la même manière, l’API définit un channel handler pour Netty.

object SayHelloHandler extends
	unfiltered.netty.cycle.Planify(SayHello.intent)

Unfiltered utilise des classes génériques HttpRequest et HttpResponse pour encapsuler les objets propres à l’interface serveur sous-adjacente. Cependant, chacune d’entre elles, expose une méthode ‘underlying’ donnant un accès direct aux objets en question. Ainsi, dans un contexte de conteneur de servlets, il reste possible de manipuler directement les objets HttpServletRequest/HttpServletResponse au sein de votre application.

Extractors

Intéressons-nous plus en détail à l’implémentation de notre Intent et plus précisément, à la manière dont une HttpRequest est routée. Comme mentionné un peu plus tôt, Unfiltered repose sur l’utilisation du pattern matching mais également sur celle des extracteurs. Les extracteurs sont un des mécanismes Scala permettant d’extraire des données d’un objet sur lequel est appliqué un pattern matching.
La plupart des extracteurs, fournis par l’API d’Unfiltered, définissent une méthode unapply() qui accepte en argument un objet HttpRequest:

def unapply(x: HttpRequest): (Y, HttpRequest)

Souvent, cette méthode retournera l’objet extrait de la requête sous la forme d’un Option[T]. Cependant, il peut s’avérer utile de retourner l’objet HttpRequest pour permettre le chaînage des extracteurs entre eux.
Dans l’exemple ci-dessus, nous utilisons quelques-uns d’entre eux pour router notre requête en fonction du Path( ) ou encore de la méthode HTTP avec GET(_) et POST(_). On trouve également l’objet Seg qui permet d’extraire une liste de String depuis les différents éléments du Path (séparés par des slashes (‘/’)). Enfin, nous utilisons l’objet Params pour extraire les paramètres HTTP sous la forme d’une Map[String, Seq[String]].

Unfiltered possède de nombreux autres extracteurs, très intuitifs de par leur nom, pour matcher une HttpRequest. Cependant, vous serez rapidement amenés à créer vos propres extracteurs dans le but d’extraire le corps de la requête dans un certain format (par exemple Json) ou encore valider des paramètres. La validation des données étant une problématique récurrente, dès qu’il s’agit de manipuler des données client, Unfiltered propose la classe Params.Extract pour faciliter la création d’extracteurs dont le rôle s’y prête.

L’utilisation de l’extracteur Params ne permet pas d’effectuer de contrôle sur les données dans le cas d’une requête POST; Il serait alors tout à fait possible de passer un paramètre vide.

Il suffit alors de définir un extracteur à l’aide de la classe Params.Extract, et de le chaîner à l’extracteur Params:

object NonEmptyName extends Params.Extract("name",Params.first ~> Params.nonempty)

Le premier paramètre correspond au nom du paramètre à extraire. Le deuxième paramètre correspond à un ParamMapper permettant d’effectuer une transformation sur le paramètre extrait. La définition de cette fonction est la suivante: Seq[String] => Option[T]. Il peut alors s'agir d’une condition de filtre ou d’une simple méthode de transformation. Unfiltered fournit un certain nombre de ParamMapper tel que Params.first et Params.noempty, qui sont chaînables via la méthode ~>.

object SayHello {
 
  val intent = unfiltered.Cycle.Intent[Any, Any] {
    case req@Path(Seg("hello" :: name :: Nil)) => req match {
      case GET(_) => 
	Ok ~> ResponseString("Hello " + name)
      case _ => MethodNotAllowed ~> ResponseString("Method must be GET")
    }
    case req@Path("/hello") => req match {
      case POST(_) & Params(NonEmptyName(name)) =>
	Ok ~> ResponseString("Hello " + name.toString)
      case POST(_) => 
	BadRequest ~> ResponseString("""The parameter "name" is missing""")
    case _ => MethodNotAllowed ~> ResponseString("Method must be POST")
    }
  }
}


Note : Au-delà d’effectuer une simple validation, l’extracteur créé nous permet de router la requête en fonction de la présence ou non d’un paramètre.

ResponseFunction

Enfin, une Intent doit retourner une ResponseFunction. Pour cela, L’API offre des implémentations appelées Responders qui sont chaînables via l’utilisation de la méthode ~>. Ainsi, dans l’exemple ci-dessus les réponses sont composées en chaînant les Responders ‘Ok’/’MethodNotAllowed’ et ‘ResponseString’. De plus, l’utilisation des codes retour HTTP n’étant pas très explicites Unfiltered définit un objet pour chaque code retour, ici Ok pour Status (200).

Configuration serveur

Maintenant que vous connaissez tout, ou presque, des mécanismes d’Unfiltered, il est temps de tester notre premier exemple.
Vous pouvez, dans un premier temps, télécharger le projet SBT ou Maven sur le repository GitHub suivant:
https://github.com/Zenika/unfiltered-demo-json

Note: le projet contient l’ensemble des exemples qui sont présentés dans la suite de ce billet.

Une première approche consiste à ajouter la déclaration du SayHelloFilter dans le descripteur de déploiement web.xml de votre application, avant de la déployer dans votre conteneur de servlets favori.

<web-app>
	<filter>
		<filter-name>SayHello</filter-name>
		<filter-class>com.example.filter.SayHelloFilter</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>SayHello</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>
</web-app>

Mais, il est très probable que beaucoup de personnes trouvent cette approche quelque peu fastidieuse. Heureusement, Unfiltered nous facilite la vie et offre la possibilité de réaliser des applications standalones, grâce à une intégration native de Jetty en tant que serveur embarqué. Pour cela, il suffit de créer un point d’entrée à notre application de la manière suivante:

object Server {
  def main(args: Array[String]) {
    unfiltered.jetty.Http.local(8080)
      .filter(SayHelloFilter)
      .run
  }
}

Un cas concret - Implémentation de services {Json}

Nous allons maintenant réaliser une application CRUD qui stockera des recettes de cocktails et exposera uniquement des services REST/Json.

Nous utiliserons pour cela, la classe suivante pour modéliser notre objet métier Cocktail.

package com.zenika.unfiltered.demo.domain
 
object Cocktail {
  type Ingredient = String
  type Quantity   = String
}
 
case class Cocktail(
  val name: String,
  val recipe: String,
  val ingredients: List[(Cocktail.Ingredient, Cocktail.Quantity)]
)

Pour manipuler des données au format Json, Unfiltered s’appuie sur la librairie Json de Lift (https://github.com/lift/lift/tree/master/framework/lift-base/lift-json/). Cette librairie offre, en autre, une DSL pour écrire directement du Json en Scala ainsi que des parsers pour convertir des cases classes en Json et inversement. Si vous n’êtes pas familiarisés avec cette librairie, je vous invite à lire la documentation. Néanmoins cela n’est pas requis pour la suite de ce billet.

Unfiltered fournit :

  • unfiltered.response.Json; Il s’agit d’un Responder pour retourner une réponse au format Json.
  • unfiltered.request.JsonBody; Il s’agit d’un extracteur pour transformer les données.

Ces objets manipulant la classe net.liftweb.json.JsonAST.JValue, nous allons définir une conversion implicite de notre objet Cocktail en un objet JValue. Pour cela, nous utiliserons simplement la DSL offerte par l’API Json.

Pour des raisons d’organisation, les conversions seront définies dans un objet CocktailRepresentations que nous pourrons, par la suite, importer dans notre Plan.

package com.zenika.unfiltered.demo.representation
 
import com.zenika.unfiltered.demo.domain.Cocktail
 
import net.liftweb.json.JsonDSL._
import net.liftweb.json.JsonAST.JValue
 
object CocktailRepresentation {
 
  implicit def toJValue(cocktails: Seq[Cocktail]): JValue =
  ("cocktails" -> cocktails.map(c => toJValue(c)))
 
  implicit def toJValue(cocktail: Cocktail): JValue =
    ("name" -> cocktail.name) ~
    ("recipe" -> cocktail.recipe) ~
    ("ingredients" ->
      cocktail.ingredients.map(i =>
      ("name" -> i._1) ~
      ("quantity" -> i._2))
    )
}

Note: Dans les conversions implicites ci-dessus, les méthodes ‘~’ et ‘->’ font partie de la DSL Json de Lift.

Nous pouvons dès à présent implémenter les services GET qui seront accessibles via les commandes CURL suivantes:

curl -X GET http://localhost:8080/cocktails --header “Accept: application/json”
curl -X GET http://localhost:8080/cocktails/{id} --header “Accept: application/json”

Voici l’implémentation des services REST. Pour rester simple, nous utilisons une Map pour enregistrer les cocktails.

package com.zenika.unfiltered.demo.plan
 
import unfiltered.request._
import unfiltered.response._
 
import com.zenika.unfiltered.demo.domain.Cocktail
import com.zenika.unfiltered.demo.representation.CocktailRepresentation._
 
object CocktailPlan extends unfiltered.filter.Plan {
 
  val repository: scala.collection.mutable.Map[Int, Cocktail] = scala.collection.mutable.Map.empty
 
  def intent = {
    case req@ Path( Seg( "cocktails" :: id :: Nil ) ) => req match {
      case GET(_) => req match {
	case Accepts.Json(_) =>
	  repository.get(id.toInt).map(
	  (c: Cocktail) => Ok ~> Json(c) ) getOrElse{NotFound ~> ResponseString("resource not found") }
	case _ => 
	  NotAcceptable ~> ResponseString("You must accept application/json")
      }
    }
    case req@ Path("/cocktails") => req match {
      case GET(_) => req match {
	case Accepts.Json(_) => Ok ~> Json(repository.values.toSeq)
	case _ => 
	  NotAcceptable ~> ResponseString("You must accept application/json")
      }
    }
  }
}

Avant d’implémenter nos services PUT et POST, il est nécessaire de convertir un flux Json en un objet Cocktail. Unfiltered propose la classe unfiltered.request.JsonBody qui permet d’extraire les données d’une requête au format Json en un objet JValue de la librairie Lift. Son utilisation étant quelque peu limitée, nous allons définir deux nouvelles classes pour faciliter la manipulation de nos objets.

package com.zenika.unfiltered.demo.common
 
import com.zenika.unfiltered.domain.Cocktail
import unfiltered.request._
import net.liftweb.json.JsonAST._
 
class JsonMapper[A](f: Option[JValue] => Option[A]) extends (Option[JValue] => Option[A]) {
  def apply(oj: Option[JValue]) = f(oj)
}
 
object JsonObjectBody  {
 
  implicit val formats = net.liftweb.json.DefaultFormats
 
  class Extract[A]( f: JsonMapper[A] ) {
 
    def this(  ) (implicit mf: Manifest[A]) = this( new JsonMapper[A]( _.map(_.extractOpt[A]).getOrElse(None)) )
 
    def unapply[T](req: HttpRequest[T]): Option[A] = f( unfiltered.request.JsonBody(req) )
 
  }
}

La classe JsonMapper n’est rien d’autre qu’une fonction qui prend en argument un Option[JValue] et qui retourne un Option[A]. Comme son nom l’indique, son rôle est de mapper un objet Json vers notre objet métier, qui dans notre cas n’est autre que Cocktail.

Ensuite, l’objet JsonObject contient une classe Extract dont le but est de faciliter la création d’extracteurs à l’aide d’un JsonMapper. Par défaut, elle utilise un JsonMapper qui transforme naturellement l’objet Json en un certain objet de type A via la méthode extractOpt[A] de la librairie Lift.

Nous allons, cependant, devoir créer un Mapper spécifique pour notre objet. En effet, si vous regardez bien la structure de classe Cocktail, vous remarquez que les ingrédients sont représentés par une List[(Cocktail.Ingredient, Cocktail.Quantity)].

Traduit directement en Json, nous aurons un flux sous la forme :

{ingredients: [{"_1": "rhum", "_2": "100ml"}, {"_1": "orange", "_2": "300ml"}]}

Vous conviendrez que l’utilisation de la notation Scala n’est ici pas très explicite. Il serait préférable d’avoir un flux Json de la forme suivante:

{ingredients: [{"name": "rhum", "quantity": "100ml"}, {"name": "orange", "quantity": "300ml"}]}

Pour cela, il est nécessaire de transformer, après extraction, l’objet Json pour le faire matcher avec la classe Cocktail. Cette opération est réalisable à l’aide de la méthode transform( ) de la classe JValue qui accepte en argument une fonction partielle pour réaliser le mapping.
Nous pouvons alors nous en servir pour créer l’Extracteur suivant:

import unfiltered.request._
import unfiltered.response._
import com.zenika.unfiltered.demo.domain.Cocktail
import com.zenika.unfiltered.demo.common.{JsonBody, JsonMapper}
import net.liftweb.json.JsonAST.{JValue, JObject, JField}
 
object CocktailBody extends JsonBody.Extract[Cocktail](new JsonMapper((o: Option[JValue]) => {
 
  implicit val formats = net.liftweb.json.DefaultFormats
 
  o.map(_.transform {
    case JObject(List(JField("name", n), JField("quantity", q))) => JObject(List(JField("_1", n), JField("_2", q)))
  }.extractOpt[Cocktail]).getOrElse(None)
}))

Il est alors possible d’utiliser cette classe comme tout autre extracteur pour matcher l’HttpRequest.
Note: une partie du code a été omise pour ne pas surcharger l’article.

case req@ Path( Seg( "cocktails" :: id :: Nil ) ) => req match {
  // PUT /cocktails/{id} --header "Content-Type: application/json"
  case PUT(_) & RequestContentType(ct) => ct match {
    case "application/json" => req match {
      case CocktailBody(c) => {
	if( repository.contains(id.toInt)) {
	  repository.update(id.toInt, c)
	  Ok ~> ResponseString("The cocktail has been successfully updated")
	} else NotFound ~> ResponseString("resource not found")
      }
      case _ => BadRequest ~> ResponseString("data must be valid application/json")
    }
 
    case _ => UnsupportedMediaType ~> ResponseString("content-type must be application/json")
  }
  case req@ Path("/cocktails") => req match {
    // POST /cocktails --header "Content-Type: application/json"
    case POST(_) & RequestContentType(ct) => ct match {
      case "application/json" => req match {
	case CocktailBody(c) => {
	  repository += (repository.lastOption.map(x => x._1).getOrElse(0) + 1) -> c
	    Created ~> ResponseString("The cocktail has been successfully created")
	}
	case _ => BadRequest ~> ResponseString("Invalid json data")
    }
  }
}

Nous pouvons maintenant ajouter notre premier cocktail. Pour cela, vous pouvez utiliser la commande suivante:

curl -i -X POST http://localhost:8080/cocktails \
--header "Content-Type:application/json" \
--data '{"name":"Mojito","recipe":"Mélanger les ingrédients","ingredients":[{"name":"Rhum","quantity":"6cl"},{"name":"jus de citron","quantity":"3cl"},{"name":"eau gazeuse","quantity":"30cl"}]}'


Mais vous avez oublié l’ingrédient secret! Vous pouvez modifier votre cocktail avec la commande suivante:

curl -i -X PUT http://localhost:8080/cocktails/1 \
--header "Content-Type:application/json" \
--data '{"name":"Mojito","recipe":"Mélanger les ingrédients","ingredients":[{"name":"Rhum","quantity":"6cl"},{"name":"jus de citron","quantity":"3cl"},{"name":"eau gazeuse","quantity":"30cl"}, {"name": "feuilles de menthe", "quantity":"3"}]}'

Conclusion


Au-delà du fait qu’Unfiltered offre l’avantage d’être simple d’approche, celui-ci n’impose aucune contrainte en terme d’implémentation. La manière dont une Intent doit être définie est ainsi laissée aux développeurs. Unfiltered reste extensible de par la simplicité de son API. Bien que l’on puisse lui reprocher certains manques sur le binding des paramètres, vous pouvez être certain qu’il n’y a aucune magie derrière le traitement des requêtes. Et, comme le dit Maxime Lévesque (créateur de l’ORM Squeryl écrit en Scala):

“In my opinion, Unfiltered’s main strength is in what it doesn’t do.”


Enfin, pour les points négatifs (il y en a toujours), la documentation n’est pas forcément complète et il peut rapidement s’avérer nécessaire de regarder le code source.
N’hésitez pas à consulter directement la documentation d’Unfiltered pour plus d’éléments (gestion des services asynchrones, des cookies ou encore de l’authentification). Par ailleurs, je vous invite aussi à regarder la présentation faite par Nathan Hamblen; http://vimeo.com/39951111. Merci à vous et n'hésitez pas à me faire part de vos remarques ou de vos retours d’expérience sur Unfiltered.

Références

Documentation Unfiltered: http://unfiltered.databinder.net/
Slides de Présentation, par Doug Tangren: http://unfiltered.lessis.me/
Github Lift-Json: https://github.com/lift/lift/blob/master/framework/lift-base/lift-json/README.md
Github Unfiltered: https://github.com/softprops/Unfiltered/blob/master/README.markdown


Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.