Capture de contexte avec le framework Play

Play est un framework de développement Web dit fullstack: il propose la gestion d’un site Web, aussi bien au niveau du backend que de celui du frontend. Dans cet article, je vous propose une technique, inspirée de ce qui est proposé par Pekko HTTP, afin de capturer efficacement le contexte dans lequel le backend répond aux requêtes qu’il reçoit.

Introduction

Play est un framework de développement Web fullstack. Il propose essentiellement de décrire les pages Web telles qu’elles doivent être présentées à l’utilisateur grâce à un système de template: vous écrivez votre code HTML et vous y ajouter des variables qui peuvent être traitées grâce à un langage de programmation afin de générer le code HTML final. En Play, ce langage est un DSL basé soit sur Scala, soit sur Java.

Pour le backend, Play propose de définir des routes qui spécifient la méthode à appeler lorsqu’une URL est appelée par un client. Vous implémentez alors cette méthode en Java ou en Scala, typiquement en réalisant le rendu d’une page HTML grâce au système de template du frontend.

Un besoin récurrent du backend est de se réapproprier le contexte dans lequel une requête a lieu: un utilisateur est-il authentifié? Si l’application gère plusieurs tenants, dans le cadre de quel tenant l’utilisateur réalise-t-il son action? L’utilisateur a-t-il la permission de réaliser le travail demandé?

Akka et Pekko HTTP proposent une solution assez élégante pour répondre à ces questions. Elle est facilement transposable en Play, du moins si vous programmez en Scala.

L’approche de Pekko HTTP

Akka HTTP et son alternative Pekko proposent une approche fonctionnelle pour décrire les méthodes de réponse aux requêtes HTTP. Play étant basé sur Pekko, il existe une bonne intégration entre ces deux technologies. Cependant, même sans recourir explicitement à Pekko, il est possible de s’inspirer de son approche pour gérer la récupération du contexte de l’exécution d’une requête.

En Pekko HTTP, le traitement d’une requête HTTP est défini comme une fonction recevant une requête et fournissant un résultat. Une collection de fonctions synchrones, et une autre faite de fonctions asynchrones, permettent de gérer les situations suffisamment simples que pour utiliser des fonctions retournant simplement la réponse attendue, tout comme celles où un Future d’une valeur doit être retourné afin de permettre le traitement asynchrone et non bloquant des traitements réalisés.

Un principe de base de la bibliothèque est la composition de fonctions. Par exemple, une route peut être définie comme suit:

path("hello") {
   get {
       complete(HttpEntity(ContentTypes.`text/plain(UTF-8)`, "Hello, Pekko HTTP!"))
   }
}

La première ligne est un appel à la fonction path, capable de repérer une structure dans l’URL de la requête. Si la structure définie n’est pas repérée pour une requête donnée, la fonction retourne une valeur indiquant qu’elle abandonne. Dans le cas contraire, on applique la fonction passée en second paramètre de la fonction, en l’occurrence un appel à la fonction get. Cette fonction vérifie le verbe de la requête, et jette l’éponge s’il ne s’agit pas de GET. Dans le cas contraire, elle invoque la fonction qui lui est donnée en paramètre, en l’occurrence un appel à la fonction complete qui retourne une réponse HTTP.

On crée donc des chemins en appelant des méthodes décrivant le comportement à adopter face à une requête. Ces fonctions soit déclarent qu’elles ne peuvent faire aboutir la requête, soit y répondent, le plus souvent en sous-traitant la formulation de la réponse à une autre fonction, et ce jusqu’à parvenir à une fonction qui fournit platement le contenu de la réponse.

On peut composer plusieurs fonctions, de sorte que le moteur de Pekko HTTP évalue chacune d’entre elles, jusqu’à tomber sur une fonction qui réponde favorablement à la requête:

val helloRoute = path("hello") {
   get {
       complete(HttpEntity(ContentTypes.`text/plain(UTF-8)`, "Hello, Pekko HTTP!"))
   }
}

val greetRoute = path("greet" / Segment) { name =>
   get {
      val greeting = Greeting(s"Hello, $name!")
      complete(HttpEntity(ContentTypes.`application/json`, greeting.toJson.prettyPrint))
   }
}

val routes = helloRoute ~ greetRoute

Un élément intéressant de cet exemple est la prise en compte d’un paramètre par la fonction path. En effet, dans cet exemple, on s’attend à récupérer un segment (pour faire simple, une chaîne de caractères) de la structure de l’URL. Ce segment devient un paramètre: la méthode path prend en second argument une fonction qui reçoit un paramètre de type Segment et en fait quelque chose. Dans notre exemple, ce second argument est, à nouveau, un appel à la fonction get, qui elle-même ne fait rien du segment, mais son propre argument, une fonction anonyme, l’utilise pour définir la variable greeting. Il y a donc eu extraction d’une information dans une fonction extérieure, et transmission de cette information pour son traitement par une fonction imbriquée.

Récupération de contexte avec Play

Comment mettre en œuvre ce mécanisme pour répondre aux questions que nous nous posions dans l’introduction?

Tout d’abord, en profitant d’une fonction offerte par Play, qui expose un paramètre request contenant, notamment, les propriétés d’une éventuelle session de navigation:

def route(): Result = messagesAction { implicit request: MessagesRequest[AnyContent] =>
   request.session.get("user_id") match {
      case Some(userId: String) => Ok(s"Bonjour, ${userId})
      case None => Unauthorized
   }
}

La méthode messageAction reçoit une fonction qui prend une requête (oublions ici le mot-clef implicit, qui indique que ce paramètre sera fourni automatiquement, et qui est l’objet d’un autre article) et qui retourne un résultat de type Ok (soit une réponse HTTP 200). En ligne 3, le paramètre request est utilisé pour définir la valeur de userId: c’est une première récupération de contexte. Si la session ne contient pas d’identifiant d’utilisateur, nous retournons un Unauthorized (réponse HTTP 401).

Cet identifiant d’utilisateur n’est qu’une chaîne de caractères, alors que nous voudrions travailler avec une instance d’une classe représentant l’utilisateur, pour autant qu’un tel utilisateur ayant cet identifiant existe bien dans la base de données. Dans le cas contraire, nous préférerions systématiquement retourner un résultat de type Unauthorized, soit une réponse HTTP 401. La conversion de l’identifiant de l’utilisateur en un utilisateur va nécessiter un appel à une base de données ou à un service tiers. Dans les deux cas, cela ne sera pas instantané. Il serait donc préférable que notre traitement devienne asynchrone et non-bloquant.

Commençons par créer une fonction qui réalise l’extraction de l’identifiant de l’utilisateur:

def withUserAsync(f: String => Future[Result]): Future[Result] = { 
   implicit request: MessagesRequest[AnyContent] =>
   
   request.session.get("user_id") match {
      case Some(userId: String) => f(userId)
      case None => Future.successful(Unauthorized)
   }
}

Si l’identifiant existe dans la session, nous le donnons à la fonction f. Dans le cas contraire, nous retournons un Unauthorized comme précédemment. Mais, puisque withUserAsync retourne un Future[Response], cet Unauthorized doit être enveloppé dans un Future. Remarquez que, bien que l’identification de l’utilisateur ait échoué, le Future lui-même est une réussite: aucun problème n’est survenu durant son exécution. Nous pouvons réécrire notre route en utilisant cette fonction:

def route() = messagesAction.async { 
   implicit request: MessagesRequest[AnyContent] =>
   withUserAsync{ userId => Future.successful(Ok(s"Bonjour, ${userId})) }
}

Cette écriture est légèrement plus compacte que la précédente. Par contre, nous travaillons à présent avec des Futures, alors que ce n’est pas vraiment nécessaire, car nous ne procédons pas encore à la récupération de l’utilisateur proprement dit. Remédions-y par une nouvelle fonction:

case class User(id: String, name: String)

def withUserAsync
  (userId: String, userProvider: String => Future[Option[User]])
  (f: User => Future[Result])
  (implicit ec: ExecutionContext): Future[Result] = {
    userProvider(userId).flatMap(_ match {
      case Some(user) => f(user)
      case None => Future.successful(Unauthorized)
    })
 }

Cette fonction a trois paires de parenthèses, rien de moins. Cette forme, qui n’est pas valide en Java, est utilisée en Scala pour exprimer une curryfication de la fonction: withUserTest est une fonction prenant deux arguments, userId et userProvider (qui est elle-même une fonction) et qui retourne une fonction prenant en argument une fonction f ainsi qu’un paramètre implicite ec. La fonction retourne un Unauthorized si aucun utilisateur n’a été trouvé pour l’identifiant donné, et applique la fonction f si un tel utilisateur a été trouvé.

Exploitons cette nouvelle fonction:

def myUserProvider(userId: String): Future[Option[User]] = ???

def route() = messagesAction.async { implicit request: MessagesRequest[AnyContent] =>
    withUserIdAsync { userId =>
        withUserAsync(userId, myUserProvider) { user =>
           Future.successful(Ok(s"Bonjour, ${user.name}"))
        } 
    }
}

L’implémentation de myUserProvider est laissée à votre discrétion. À présent, la route fournit une réponse comprenant le nom de l’utilisateur, et non plus simplement son identifiant. Si quelque chose se passe mal (identifiant non présent dans la session ou identifiant ne correspondant pas à un utilisateur dans la base de données), une réponse Unauthorized sera retournée.

À présent, occupons-nous de l’autorisation. Supposons que nous devons vérifier que l’utilisateur a le droit d’afficher un message lui souhaitant le bonjour. Cela nécessite à nouveau typiquement une requête dans une base de données ou l’appel à un service distant. Nous allons isoler cette interrogation dans une fonction asynchrone et l’utiliser dans une fonction composable avec celles précédemment définies:

def withAuthorization(condition: => Future[Boolean])(f: => Future[Result]) = 
   condition.flatMap(_ map {
      case True => f()
      case False => Future.successful(Forbidden)
}

Comme précédemment, la fonction est curryfiée pour distinguer la condition de la réponse à fournir. Exploitons cette fonction:

def isUserAllowedToHello(userId: String): Future[Boolean] = ???

def route() = messagesAction.async { implicit request: MessagesRequest[AnyContent] =>
    withUserIdAsync { userId =>
        withAuthorization(isUserAllowedToHello(userId)) {
           withUser(userId, myUserProvider) { user =>
              Future.successful(Ok(s"Bonjour, ${user.name}"))
           }
        }
    }
}

L’implémentation de la fonction isUserAllowedToHello est laissée à votre discrétion. Notez que l’appel à withAuthorization se trouve juste après withUserIdAsync, car la fonction vérifiant la permission n’a pas besoin de l’utilisateur entier, mais seulement de son identifiant. Ainsi, si l’autorisation n’est pas accordée, nous ne récupérons pas inutilement l’utilisateur depuis une source externe.

Conclusion

La récupération de l’identifiant d’un utilisateur à partir du contexte d’exécution, par exemple une session, ainsi que la transformation de cet identifiant en un utilisateur complet lorsque c’est possible et la vérification de l’autorisation qu’a cet utilisateur pour réaliser une action, sont des actions typiques dans un backend. Le comportement à adopter lorsque ces opérations échouent sont tout aussi typiques.

Je propose une approche, inspirée par Pekko HTTP mais pouvant s’appliquer avec Play ou pouvant être généralisée à n’importe quel système devant récupérer progressivement des éléments du contexte d’exécution. Cette approche est basée sur l’implémentation de chaque responsabilité dans des fonctions distinctes, qui sont généralement asynchrones car elles nécessitent un appel à une base de données ou à un service distant.

Les fonctions sont composées afin d’en combiner les effets. Comme ces fonctions prennent en argument d’autres fonctions, il est facile de modifier leur comportement: il suffit de modifier les fonctions qui leur sont données en paramètre.

Enfin, ces fonctions gèrent systématiquement les situations où quelque chose se passe mal: elles retournent immédiatement la réponse appropriée en cas de problème, et sous-traitent la formulation de la réponse à une autre fonction lorsque tout va bien.

Cette approche est donc généralisable, extensible et sûre grâce à la composition des fonctions et la gestion systématique des cas problématiques.

2 réflexions sur “Capture de contexte avec le framework Play

  1. Pingback: Vous reprendez bien un peu de curry avec votre programme? | Le compas de l'architecte

  2. Pingback: Loaner pattern en Scala | Le compas de l'architecte

Répondre à Vous reprendez bien un peu de curry avec votre programme? | Le compas de l'architecte Annuler la réponse.