Home > OS >  Play/Scala: use orElse to compose with ActionBuilder?
Play/Scala: use orElse to compose with ActionBuilder?

Time:10-20

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 ?

CodePudding user response:

I don't think you'll be able to compose in a orElse manner the Actions.

However you should be able to create a "combined" ActionBuilder that uses your 2 existing ActionBuilders and do the orElse logic. Though you would only be able to provide one body to run. And this body would have to rely on something like the AuthenticatedRequest#profiles to determine what to do.

Something like:

def index = ACTION_COMBINED.async{ req: AuthenticatedRequest =>
  // Check something on req.profiles
  if (...) index1(req) else index2(req)
}

I'm not familiar with play-pac4j to be more precise

CodePudding user response:

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)
}
  • Related