Accueil > Non classé > Concevoir une API RESTful avec Spray.io

Concevoir une API RESTful avec Spray.io

Introduction

Spray

Spray est un projet proposant plusieurs librairies scala destinées à facilier la construction d’APIs performantes utilisant HTTP.
Elles fournissent des fonctionnalités asynchrones et non bloquantes en se basant sur les acteurs Akka et sur Java NIO. Le tout restant léger, modulaire et testable.

Dans cet article je vous propose un petit tour d’horizon de l’usage standard de Spray en mettant en place une petite application RESTful gérant des employés.
En particulier nous utiliserons la librairie spray-routing, la couche d’abstraction la plus élevée de Spray, qui permet de construire rapidement une API REST.

Le DSL HTTP de spray-routing

Avec spray, les règles de routage http sont du code. Pour tester facilement l’aboutissement fonctionnel de ce routage, la librairie spray-testkit fournit un support à specs en ajoutant un DSL plutôt pratique de la forme:

1
2
3
"Requête http" ~> "Route à tester" ~> check {
   "Assertions sur la réponse http obtenue après traitement de routage"
}

Explicitons son usage avec une première fonctionnalité, renvoyons le nom de l’employé lorsqu’un GET pour son identifiant est reçu.

1
2
3
4
5
6
7
8
9
"The employee resource" should {
   "return the name of the employee for GET requests with an id" in {
      Get("/employee/2") ~> employeeResourceRoute ~> check {
         status === OK
         mediaType === `text/plain`
         body must not beEmpty
      }
   }
}

Nous nous assurons ici que la réponse http obtenue aura un statut 200, un content-type text/plain et un corps non vide.
La lisibilité de notre test est plutôt bonne et colle de près le vocabulaire http: Get, status, OK, mediaType (et non content-type qui lui inclut le charset en plus), body. Les valeurs OK et `text/plain` font parties de spray-http, module dans lequel nous retrouvons les valeurs d’en-têtes HTTP standards, encapsulées dans des classes immutables. Le module est indépendant et est notamment conçu pour pouvoir être utilisé plus largement sur tout type de projets ou par d’autres librairies désirant abstraire l’usage d’HTTP.

Notre test est écrit, passons à l’implémentation de la route employeeResourceRoute.
Voici ce que ça donne:

1
2
3
4
5
6
7
8
val employeeResourceRoute =
   get {
      path("employee" / IntNumber) { employeeId =>
         complete {
            retrieveEmployeeNameById(employeeId)
   } } }

def retrieveEmployeeNameById(id: Int): Future[String] = // Récupération du nom de l'employé.

Le DSL de spray-routing met à disposition les directives get, path et complete pour composer notre route de manière concise, lisible et factorisable.
Ici les paramètres de la réponse http sont implicites, notre code se concentre sur le contenu envoyé et la librairie s’occupe du reste: nom de l’employé placé dans le contenu de la réponse avec un type du média transporté “text/plain”, un status 200, le charset du serveur est utilisé pour encoder, etc.
A noter que la méthode complete, dont le rôle est de terminer le traitement de la requête et transmettre la réponse, ne prend pas nécessairement un Future en paramètre, le résultat peut être directement retourné.

Voici un autre exemple dont la construction de la réponse http est davantage explicitée: Pour la création d’un employé nous renvoyons le statut 201 (Created) et l’uri de la nouvelle ressource créée dans le header Location:
Voici le test:

1
2
3
4
5
6
"create an employee for PUT requests to the employee path" in {
   Put(uri = "/employee", content = "Kayser") ~> employeeResourceRoute ~> check {
      status === Created
      header[Location] must beSome[Location].which(_.value.matches("/employee/[0-9]+"))
   }
}

Et l’implémentation:

1
2
3
4
5
6
7
8
9
10
11
12
val employeeResourceRoute =
  // ... route précédente ...
  ~ put {
     path("employee") {
        entity(as[String]) { name =>
           complete {
              persistNewEmployee(name).map { newId =>
                 HttpResponse(
                    status = Created,
                    headers = Location(s"/employee/$newId") :: Nil
                 )
  } } } } }

La directive entity (entity au sens http) précise le type de contenu de la requête et prend implicitement un désérialiseur en paramètre. Ici il s’agit d’une conversion en chaîne de caractères, fournie par défaut, mais il est également possible de désérialiser en un objet du domaine grâce aux fonctions d’unmarshalling de spray-httpx. Approfondissons en nous intéressant à la sérialisation json.

Conversion JSON

Pour transformer une instance scala en json, et inversement construire une instance à partir du json, un objet json protocol doit être défini dans lequel nous déclarons le format du type à sérialiser. Pour notre application d’employées on aurait:

1
2
3
4
5
case class Employee(id:Option[Long], name:String, age:Int)

object ApplicationProtocol extends DefaultJsonProtocol {
   implicit val EmployeeFormat = jsonFormat3(Employee)
}

Il suffit ensuite de placer le format dans la portée de l’appel de la méthode entity pour que la désérialisation soit implicite:

1
2
3
4
5
6
7
8
import EmployeeProtocol._

// ...
put {
   path("employee") {
      entity(as[Employee]) { name =>
         // ...
} } }

Et pour sérialiser c’est d’autant plus implicite étant donné qu’il suffit juste de retourner l’objet pour qu’il soit transformé :

1
2
3
4
5
6
7
get {
   path("employee") {
      complete {
         retrieveEmployees() // la liste d'objets retournée sera implicitement convertie en une liste json d'employés
} } }

def retrieveEmployees(): Future[Iterable[Employee]] = //...

Le code résultant est plutôt concis grâce aux diverses fonctions utilisées, étudions leur fonctionnement pour bien comprendre.
Le trait DefaultJsonProtocol, de spray-json, fournit les fonctions de conversion des types de base de scala (Int, String, Option, TupleN, collections, etc.). Son extension par l’objet ApplicationProtocol les rend accessibles pour pouvoir écrire facilement les convertisseurs métiers de l’application. Le trait expose également des fonctions utilitaires, comme les jsonFormatN que nous utilisons ici pour être encore plus concis dans la définition du format.
Les méthodes jsonFormatN définissent un format pour une case classe en se basant sur le nom de ses attributs. Le “N” dénombre les paramètres passés à la méthode apply de l’objet du modèle et doit être précisé par le développeur pour valider les paramètres du format à la compilation.

Pour préciser davantage le format en ayant plus de contrôle sur l’instance (dé)sérialisée, il suffit d’étendre RootJsonFormat et d’implémenter les méthodes read et write:

1
2
3
4
5
6
7
8
9
10
11
12
implicit val EmployeeJsonFormat = new RootJsonFormat[Employee] {
   // Serialisation d'une instance d'employé en Json
   def write(emp: Employee) = JsObject(
      "name" -> JsString(emp.name),
      "age" -> JsNumber(emp.age)
   )
   // Désérialisation du Json en une instance d'employé
   def read(json: JsValue) = json.asJsObject.getFields("name", "age") match {
      case Seq(JsString(name), JsNumber(age)) =>
         Employee(None, name, age.toInt)
   }
}

Si vous vous êtes intéressé à l’api Json de play 2.1 vous noterez que le résultat obtenu reste moins lisible qu’en play, en voici l’équivalent:

1
2
3
4
implicit val EmployeeJsonFormat: Format[Employee] = (
   (__ \ "name").format[String] ~
   (__ \ "age").format[Int]
)(Employee, unlift(Employee.unapply))

Le bon point de ce code concerne aussi les performances qu’il apporte car toute la logique de sérialisation est vérifiée à la compilation et sans usage de l’api reflection. Couplé en interne de spray-json à la librairie parboiled pour parser les données au runtime les performances de sérialisation sont selon les auteurs très bonnes. Le round 5 dubenchmark des frameworks web publié par TechEmpower récemment illustre ce qu’il en est concrètement par rapport aux autres frameworks.

Nous avons vu comment créer les routes et comment retourner du json, voyons maintenant comment exposer l’API.

Construction de l’application serveur

Dans la philosophie spray un serveur http est aussi simple qu’un acteur recevant des requêtes et renvoyant des réponses:

1
2
3
4
5
6
// Réception de requête
def receive = {
   case HttpRequest(...) =>
      // Envoi de réponse
      sender ! HttpResponse(...)
}

C’est un acteur service, il fournit le service de routage de l’application. Voici le notre:

1
2
3
4
5
6
7
8
9
10
11
12
class EmployeeResourceServiceActor extends Actor with EmployeeResourceService {
   def actorRefFactory = context

   def receive = runRoute(employeeResourceRoute) // la méthode runRoute applique la route
                                                 // adéquate en fonction de la requête reçue
}

// Service de routage découplé de l'acteur service
// le trait HttpService fournit les directives permettant l'écriture des routes
trait EmployeeResourceService extends HttpService {
   val employeeResourceRoute = // les routes définies précédemment
}

Il suffit ensuite d’indiquer l’acteur service à la couche gérant les stockets http pour rendre le service disponible.

Avec spray deux choix s’offrent à nous en fonction du type de serveur souhaité pour héberger l’application.

  1. Embarquer un serveur spray-can
  2. Spray-can est un serveur http construit sur une architecture basé sur les acteurs, comme dans les autres modules, complètement asynchrone et non bloquant.
    C’est le plus facile à mettre en place, en implémentant un trait scala.App lançant le serveur:

    1
    2
    3
    4
    object SprayServer extends App with SprayCanHttpServerApp {
       val service = system.actorOf(Props[EmployeeResourceServiceActor])
       newHttpServer(service) ! Bind("localhost", 8080) // Démarrage du serveur
    }
  3. Déployer l’application dans un container de servlets (compatible Servlet 3.0)
  4. Dans ce cas on construit une archive WAR classique.
    Avec un web.xml par exemple:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <listener>
       <listener-class>spray.servlet.Initializer</listener-class>
    </listener>

    <servlet>
       <servlet-name>SprayConnectorServlet</servlet-name>
       <servlet-class>spray.servlet.Servlet30ConnectorServlet</servlet-class>
       <async-supported>true</async-supported>
    </servlet>

    <servlet-mapping>
       <servlet-name>SprayConnectorServlet</servlet-name>
       <url-pattern>/*</url-pattern>
    </servlet-mapping>

    Le listener spray.servlet.Initializer récupére dans le classloader la classe implémentant spray.servlet.WebBoot qui précise l’acteur service à utiliser:

    1
    2
    3
    4
    5
    class Boot extends WebBoot {
       val system = ActorSystem("employee-app")

       val serviceActor = system.actorOf(Props[EmployeeResourceServiceActor])
    }

    Pour chaque requête reçue par la servlet Servlet30ConnectorServlet, un message sera envoyé à l’acteur service précisé par la classe WebBoot.

Conclusion

Nous avons pu voir dans cet article que spray fournit une API lisible et fidèle au vocabulaire http grâce à son DSL. Les librairies sont bien isolées entres-elles et laissent la liberté de sélectionner uniquement les modules utilisés par l’application.

De part sa performance et sa flexibilité, spray suscite l’intérêt de la communauté mais également en entreprise car il constitue une alternative sérieuse pouvant s’insérer facilement dans un projet existant. N’hésitez donc pas vous aussi à le considérer et, si votre entreprise le permet, à vaporiser votre projet avec!

Pour aller plus loin

Share
Categories: Non classé Tags: , , , , ,
  1. Arnaud Gourlay
    24/06/2013 à 14:59 | #1

    Bon article!

    Tu devrais mettre à jour ta version de Spray (et akka au passage) au moins vers 1.1-M8 car elle introduit de gros changements avec l’introduction du package akka.io et également la modification du trait SimpleRoutingApp.

    http://spray.io/documentation/1.1-M8/spray-routing/

  2. Charles KAYSER
    25/06/2013 à 10:45 | #2

    @Arnaud Gourlay
    en effet faut que je regarde ça, merci!

    J’en profite pour préciser aux autres lecteurs que l’article concerne la version 1.1-M7 de spray

  3. 22/08/2014 à 02:34 | #3

    I’d veunrte that this article has saved me more time than any other.

  4. 22/02/2015 à 05:30 | #4

    This post has helped me think things thougrh

  1. 28/12/2016 à 22:47 | #1