Search code examples
scalaplayframeworkpac4j

Play/Scala: use orElse to compose with ActionBuilder?


We use play-pac4j for our authentication in our play application.

We would like to have the same route/controller endpoint but with a different behaviour dependending on the user Role.

Conceptually, this would do something like:

 val ACTION_ONE: ActionBuilder[Request, AnyContent] = Secure(
    JWT_CLIENT,  Authorizers.Role1
 )(anyContentLarge)

 val ACTION_TWO: ActionBuilder[Request, AnyContent] = Secure(
    JWT_CLIENT,  Authorizers.Role2
 )(anyContentLarge)

 def index = ACTION_ONE.async{ req => index1(req) } orElse ACTION_TWO.async{ req => index2(req) }

 def index1(req: Request[AnyContent]) = //... behavior with role1

 def index2(req: Request[AnyContent]) = //... behavior with role2

But the composition of Play Actions provides only andThen, no orElse. Is there a way to achieve that ?


Solution

  • So finally I implemented it :)

    It uses 'fallBackToNext', a method we already have in our codebase that behave as fallBackTo but with an async lambda parameter, so the next future is executed only if the first one is already a failure (preventing big computations from happening when not needed, but reducing parallelism).

    Here are most of the logic:

    
    /**
     * This combination of action is the implementation class of the "orElse" operator,
     * allowing to have one and only one action to be executed within the given actions
     */
    class EitherActions[A](actions: Seq[Action[A]]) extends Action[A] {
    
      require(actions.nonEmpty, "The actions to combine should not be empty")
    
      override def parser: BodyParser[A] = actions.head.parser
      override def executionContext: ExecutionContext = actions.head.executionContext
    
      /**
       * @param request
       * @return either the first result to be successful, or the first to be failure
       */
      override def apply(
          request: Request[A]
      ): Future[Result] = {
    
        // as we know actions is nonEmpty, we can start with actions.head and directly fold on actions.tail
        // this removes the need to manage an awkward "zero" value in the fold
        val firstResult = actions.head.apply(request)
    
        // we wrap all apply() calls into changeUnauthorizedIntoFailure to be able to use fallbackToNext on 403
        val finalResult = actions.tail.foldLeft( changeUnauthorizedIntoFailure(firstResult) ) {
          ( previousResult, nextAction ) =>
    
            RichFuture(previousResult).fallbackToNext{ () =>
              changeUnauthorizedIntoFailure(nextAction.apply(request))
            }(executionContext)
        }
    
        // restore the original message
        changeUnauthorizedIntoSuccess(finalResult)
      }
    
    
      /**
       * to use fallBackToNext, we need to have failed Future, thus we change the Success(403) into a Failure(403)
       * we keep the original result to be able to restore it at the end if none of the combined actions did success
       */
      private def changeUnauthorizedIntoFailure(
          before: Future[Result]
      ): Future[Result] = {
        val after = before.transform {
          case Success(originalResult) if originalResult.header.status == Unauthorized =>
            Failure(EitherActions.UnauthorizedWrappedException(originalResult = originalResult))
          case Success(originalResult) if originalResult.header.status == Forbidden =>
            Failure(EitherActions.UnauthorizedWrappedException(originalResult = originalResult))
          case keepResult@_ => keepResult
        }(executionContext)
        after
      }
    
      /**
       * after the last call, if we still have a UnauthorizedWrappedException, we change it back to a Success(403)
       * to restore the original message
       */
      private def changeUnauthorizedIntoSuccess(
          before: Future[Result]
      ): Future[Result] = {
        val after = before.transform {
          case Failure(EitherActions.UnauthorizedWrappedException(_, _, result)) => Success(result)
          case keepResult@_ => keepResult
        }(executionContext)
        after
      }
    
      def orElse( other: Action[A]): EitherActions[A] = {
        new EitherActions[A]( actions :+ other)
      }
    
    }
    
    object EitherActions {
    
      private case class UnauthorizedWrappedException(
          private val message: String = "",
          private val cause: Throwable = None.orNull,
          val originalResult: Result,
      ) extends Exception(message, cause)
    }