Allégez vos contrôleurs Play!

@ValentinKasas

Play! : présentation rapide

Play! est le framework web asynchrone/non-blocant de l'ombrelle Typesafe

Il est structuré autour du pattern MVC

Il s'intègre parfaitement avec Akka

Play! : Anatomie d'un contrôleur


object MyController {

  def myAction(param1: String) = Action.async(parse.json) {
    request =>
      // first do something with the request

      // then perhaps do something of interest

      // finally return a Future[Result]
      Future.successful(Ok("Done"))
  }
}
          

Une action = une fonction dont les paramètres sont les paremètres de query

L'action est construite au moyen d'un ActionBuilder ...

... auquel on passe un BodyParser ...

... et un fonction anonyme Request => Future[Result]

Manipuler un body en JSON

On peut valider automatiquement un body JSON et le transformer en une case class

Pour cela il faut une instance de Reads pour notre classe dans le scope implicit

Le résultat de la validation sera soit

Le problème

On va s'intéresser à un cas d'étude très simple mais qui présente quelques problèmes d'implémentation.

On veut créer une API REST pour permettre à des utilisateurs de gérer leur activité sur les réseaux sociaux.

On a :

On va écrire une action qui permet à un utilisateur d'updater son statut.

Les services


trait UserService {
  def findById(userId: String) : Future[Option[User]]
  // snip ...
}
trait SocialService {
  def updateStatus(credentials: Credentials, statusUpdate: StatusUpdate) : Future[Either[SocialError, StatusUpdate]]
  // snip ...
}
      

En bons programmeurs, on encode les effets de bord dans le typesystem.

Première mouture "Vanilla Scala"


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>


}
      

Cette action pourtant très simple pose une série de problèmes

Deuxième essai : "La cage aux folds"


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    request.body.validate[StatusChange].fold(
      error => Future.successful(BadRequest(JsError.toFlatJson(error)),
      statusUpdate =>
        userService.findById(userId).map{
          mayBeUser =>
            maybeUser.fold(NotFound)(
              socialService.updateStatus(user.credentials, statusChange).map{
                _.fold(
                  error => Forbidden,
                  success => NoContent
                )
              }
            )
        }
}
      

Il reste encore des problèmes

Ce qu'on aimerait bien

C'est quoi le boilerplate ?

Définition en creux :

Le rôle d'un controlleur c'est de

Chaque ligne d'une action doit dont correspondre à une (et une seule) de ces activités

... on constate qu'on en est assez loin

La structure de notre action

Ce traitement paraît furieusement séquentiel

Monads

Mandatory I-know-monads moment

Définissons l'idée de monade (dans la joie)

Ce qui serait bien ...

Ce serait de pouvoir exprimer chaque action avec juste une for-comprehension


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    for {
      maybeUser <- userService.find(userId)
      user <- maybeUser
      statusUpdateOrError <- request.body.parse[StatusUpdate]
      statusUpdate <- statusUpdateOrError
      _ <- socialService.updateStatus(user.credentials, statusUpdate)
    } yield NoContent
}
          

... bon, là ça compile pas, mais c'est normal

La première ligne du for "fixe" la monade pour toute la for-comprehension

Pour atteindre notre objectif, il nous faut donc un moyen d'emballer nos traitments dans la même monade

Par ailleurs, on n'a pas de moyen de capturer les cas d'erreur pour renvoyer un résultat pertinent

ScalaZ à la rescousse !

Scalaz fournit des implémentations du concept de monad transformer

Un monad transformer combine les effets de deux monades dans une nouvelle monade

Donc il nous faut "juste" trouver la bonne combinaison

Sidenote : \/

Le type scala.util.Either n'est pas monadique

Pour avoir un Either (ou sum type) monadique, il faut "choisir" un "côté"

C'est exactement ce que fait le type \/ de scalaz (on prononce Disjonction)

\/ a la même forme qu'Either, mais il est "right-biased"

càd : dans une for-comprehension sur \/ le traitement continue tant que résultat est \/-

À la recherche de notre monad transformer

Pour être uniforme il nous faudrait à chaque étage

Le type que l'on cherche est donc EitherT[Future, Result, Result]

Le bout du tunnel


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    val result = for {
      statusUpdate <- EitherT(Future.successful(request.body.validate[StatusUpdate].fold(
                        err => BadRequest(JsError.toFlatJson(err)).left,
                        su => su.right
                      )))
      user <- EitherT(userService.find(userId).map(u => u \/> NotFound))
      _ <- EitherT(socialService.updateStatus(user.credentials, statusUpdate).map{
             fe =>
               fe.fold(err => Forbidden.left, identity)
           })
    } yield NoContent

    result.run.merge
}
          

Enfin du code qui compile !

Mais c'est encore un peu verbeux/illisible

La bonne nouvelle, c'est qu'on peut généraliser/abstraire la transformation de nos résultats intermédiaires en EitherT

Notre mini DSL


object ActionDSL {
  type Step[A] = EitherT[Future, Result, A]

  def fromJsResult[A](onError: JsError => Result)(in: JsResult[A]): Step[A] = EitherT(
    Future.successful(in.fold(
      e => onError(e).left,
      s => s.right
    )
  )

  def fromFuture[A](onFailure: Throwable => Result)(in: Future[A]): Step[A] = EitherT(
    in.map(_.right)
      .recover{ case NonFatal(e) => onFailure(e).left }
  )

  def fromFOption(onNone: => Result)(in: Future[Option[A]): Step[A] = EitherT(
    in.map(a => a \/> onNone)
  )

  def fromFEither[A, B](onLeft: B => Result)(in: Future[B \/ A]): Step[A] = EitherT(
    in.map(_.leftMap(onLeft))
  )


  def fromJsResult[A](onError: JsError => Result)(in: JsResult[A]): Step[A] = EitherT(
     Future.successful(in.fold(
       e => onError(e).left,
       s => s.right
     ))
  )
  // etc ...
}
          

AcionDSL

Le DSL est extrèmement simple, il se contente de définir comment on transforme chaque type en EitherT[Future, Result, A]


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    val eitherT = for {
      statusUpdate <- request.body.validate[StatusUpdate]                        |> ActionDSL.fromJsResult(err => BadRequest(JsError.toFlatJson(err))
      user         <- userService.findById(userId)                               |> ActionDSL.fromFOption(NotFound)
      _            <- socialService.updateStatus(user.credentials, statusUpdate) |> ActionDSL.fromFEither(Forbidden)
    } yield NoContent

    eitherT.run.map(_.toEither.merge)
}
          

C'est mieux, mais il reste un tout petit peu de cérémonie

Il faut écrire le même eitherT.run... pour chaque action

Le choix de la méthode d'ActionDSL pourrait être automatique

MonadicAction

On va devoir répeter le eitherT.run dans chaque action ... c'est du boilerplate !

Pour l'éliminer, on a deux solutions


// conversion implicite

implicit def stepToResult(step: Step[Result]):Future[Result] = step.run.map(_.merge)

// ActionBuilder

import scala.language.higherKinds

case class MonadicAction[R[_]](inner: ActionFunction[Request, R]) extends ControllerUtils {

  def apply[A](bodyParser: BodyParser[A])(block: R[A] => HttpResult[Result]) = new Action[A] {
    override def parser = bodyParser

    override def apply(request: Request[A]) = inner.invokeBlock(request, (req: R[A]) => constructPlayResult(block(req)))
  }

  def apply(block: R[AnyContent] => HttpResult[Result]): Action[AnyContent] = apply(BodyParsers.parse.anyContent)(block)

}

          

Conversions implicites

Pour rendre la syntaxe plus agréable, on peut définir un nouveau type avec un opérateur qui facilitera la construction de nos Step[A]


trait StepOps[A, B] {
  def orFailWith(onFail: B => Result): Step[A]
  def ?| (onFailure: B => Result): Step[A] = orFailWith(onFailure)
  def ?| (failureThunk: =>Result): Step[A] = orFailWith(_ => failureThunk)
}

implicit def fOptionToStepOps[A](futureOption: Future[Option[A]]): ToStepOps[A, Unit] {
  override def orFailWith(onFailure: Unit => Result) = ActionDSL.fromFOption(onFailure())(futureOption)
}

implicit def jsResultToStepOps[A](jsResult: JsResult[A]): ToStepOps[A, JsError] {
  override def orFailWith(onFailure: JsError => Result) = ActionDSL.fromJsResult(onFailure)(jsResult)
}

// etc ...
          

Pour chaque type intéressant (JsResult[A], Future[Option[A]], etc...) il n'y a qu'une seule transformation pertinente en Step[A]

Écrire une transformation impilicite de chacun de nos type en Step est donc sans danger

Version Finale


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    for {
      statusUpdate <- request.body.validate[StatusUpdate]                        ?| (err => BadRequest(JsError.toFlatJson(err))
      user         <- userService.find(userId)                                   ?| NotFound
      _            <- socialService.updateStatus(user.credentials, statusUpdate) ?| Forbidden
    } yield NoContent
}
          

Pour mémoire, au début, on avait un truc qui ressemblait à ...


def updateStatus(userId: String) = Action.async(parse.json) {
  request =>
    request.body.validate[StatusChange] match {
      case JsSuccess(statusUpdate) =>
        userService.findById(userId).map{
          _.map{
            user =>
              socialService.updateStatus(user.credentials, statusChange).map{
                _.fold(
                  error => Forbidden,
                  success => NoContent
                )
              }
          }.getOrElse(NotFound)
      case JsError(error) => Future.successful(BadRequest(JsError.toFlatJson(error)),
    }
}
        

/