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
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]
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
JsSuccess
contenant une instance de notre classeJsError
contenant la liste des erreurs de validation détectéesOn 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.
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.
def updateStatus(userId: String) = Action.async(parse.json) {
request =>
}
Cette action pourtant très simple pose une série de problèmes
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
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
Ce traitement paraît furieusement séquentiel
Définissons l'idée de monade (dans la joie)
unit
et d'un bind
join
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 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
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 \/-
Pour être uniforme il nous faudrait à chaque étage
Le type que l'on cherche est donc EitherT[Future, Result, Result]
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
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 ...
}
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
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)
}
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
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)),
}
}
/