diff --git a/core/play/src/main/resources/reference.conf b/core/play/src/main/resources/reference.conf index 0ee2d1e347d..633bad98267 100644 --- a/core/play/src/main/resources/reference.conf +++ b/core/play/src/main/resources/reference.conf @@ -118,6 +118,10 @@ play { # If the action returned by the action creator should be executed before the action composition ones executeActionCreatorActionFirst = false + + # If WebSocket actions should be included in action composition. + # This config is only relevant for Play Java and will not have an effect in Play Scala. + includeWebSocketActions = false } # Cookies configuration diff --git a/core/play/src/main/scala/play/api/http/HttpConfiguration.scala b/core/play/src/main/scala/play/api/http/HttpConfiguration.scala index 543b1f64c10..bb8c5f974bb 100644 --- a/core/play/src/main/scala/play/api/http/HttpConfiguration.scala +++ b/core/play/src/main/scala/play/api/http/HttpConfiguration.scala @@ -154,10 +154,12 @@ case class ParserConfiguration( * @param controllerAnnotationsFirst If annotations put on controllers should be executed before the ones put on actions. * @param executeActionCreatorActionFirst If the action returned by the action creator should be * executed before the action composition ones. + * @param includeWebSocketActions If WebSocket actions should be included in action composition. */ case class ActionCompositionConfiguration( controllerAnnotationsFirst: Boolean = false, - executeActionCreatorActionFirst: Boolean = false + executeActionCreatorActionFirst: Boolean = false, + includeWebSocketActions: Boolean = false, ) /** @@ -235,7 +237,8 @@ object HttpConfiguration { actionComposition = ActionCompositionConfiguration( controllerAnnotationsFirst = config.get[Boolean]("play.http.actionComposition.controllerAnnotationsFirst"), executeActionCreatorActionFirst = - config.get[Boolean]("play.http.actionComposition.executeActionCreatorActionFirst") + config.get[Boolean]("play.http.actionComposition.executeActionCreatorActionFirst"), + includeWebSocketActions = config.get[Boolean]("play.http.actionComposition.includeWebSocketActions"), ), cookies = CookiesConfiguration( strict = config.get[Boolean]("play.http.cookies.strict") diff --git a/core/play/src/main/scala/play/api/mvc/Action.scala b/core/play/src/main/scala/play/api/mvc/Action.scala index 6e5c6103f2e..80a6b080347 100644 --- a/core/play/src/main/scala/play/api/mvc/Action.scala +++ b/core/play/src/main/scala/play/api/mvc/Action.scala @@ -237,7 +237,7 @@ object BodyParser { ) }) .getOrElse { - logger.trace("Not parsing body, was parsed before already for request: " + request) + logger.trace("Not parsing body, it's a WebSocket or it was parsed before already for request: " + request) next(request) } } diff --git a/core/play/src/main/scala/play/core/routing/HandlerInvoker.scala b/core/play/src/main/scala/play/core/routing/HandlerInvoker.scala index b44b9712e12..3626c589dea 100644 --- a/core/play/src/main/scala/play/core/routing/HandlerInvoker.scala +++ b/core/play/src/main/scala/play/core/routing/HandlerInvoker.scala @@ -8,16 +8,19 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.Optional +import scala.concurrent.Future import scala.jdk.FutureConverters._ import scala.jdk.OptionConverters._ import scala.util.control.NonFatal import akka.stream.scaladsl.Flow import play.api.http.ActionCompositionConfiguration +import play.api.libs.typedmap.TypedKey import play.api.mvc._ import play.api.routing.HandlerDef import play.core.j._ import play.libs.reflect.MethodUtils +import play.mvc.{ BodyParser => JBodyParser } import play.mvc.Http.RequestBody /** @@ -86,6 +89,21 @@ object HandlerInvokerFactory { } } + private def cachedAnnotations( + annotations: JavaActionAnnotations, + config: ActionCompositionConfiguration, + handlerDef: HandlerDef + ): JavaActionAnnotations = { + if (annotations == null) { + val controller = loadJavaControllerClass(handlerDef) + val method = + MethodUtils.getMatchingAccessibleMethod(controller, handlerDef.method, handlerDef.parameterTypes: _*) + new JavaActionAnnotations(controller, method, config) + } else { + annotations + } + } + /** * Create a `HandlerInvokerFactory` for a Java action. Caches the annotations. */ @@ -94,20 +112,12 @@ object HandlerInvokerFactory { // Cache annotations, initializing on first use // (It's OK that this is unsynchronized since the initialization should be idempotent.) private var _annotations: JavaActionAnnotations = null - def cachedAnnotations(config: ActionCompositionConfiguration) = { - if (_annotations == null) { - val controller = loadJavaControllerClass(handlerDef) - val method = - MethodUtils.getMatchingAccessibleMethod(controller, handlerDef.method, handlerDef.parameterTypes: _*) - _annotations = new JavaActionAnnotations(controller, method, config) - } - _annotations - } override def call(call: => A): Handler = new JavaHandler { def withComponents(handlerComponents: JavaHandlerComponents): Handler = { new play.core.j.JavaAction(handlerComponents) { - override val annotations = cachedAnnotations(this.handlerComponents.httpConfiguration.actionComposition) + override val annotations = + cachedAnnotations(_annotations, this.handlerComponents.httpConfiguration.actionComposition, handlerDef) override val parser = { val javaParser = this.handlerComponents.getBodyParser(annotations.parser) javaBodyParserToScala(javaParser) @@ -164,16 +174,23 @@ object HandlerInvokerFactory { } } + private val PASS_THROUGH_REQUEST: TypedKey[JRequest] = TypedKey("Pass-Through-Request") // Do not make this public + implicit def javaWebSocket: HandlerInvokerFactory[JWebSocket] = new HandlerInvokerFactory[JWebSocket] { import play.api.http.websocket._ import play.core.Execution.Implicits.trampoline import play.http.websocket.{ Message => JMessage } def createInvoker(fakeCall: => JWebSocket, handlerDef: HandlerDef) = new HandlerInvoker[JWebSocket] { - def call(call: => JWebSocket) = new JavaHandler { + + // Cache annotations, initializing on first use + // (It's OK that this is unsynchronized since the initialization should be idempotent.) + private var _annotations: JavaActionAnnotations = null + + def call(wsCall: => JWebSocket) = new JavaHandler { def withComponents(handlerComponents: JavaHandlerComponents): WebSocket = { WebSocket.acceptOrResult[Message, Message] { request => - call(request.asJava).asScala.map { resultOrFlow => + def callWebSocketAction(req: RequestHeader) = wsCall(req.asJava).asScala.map { resultOrFlow => if (resultOrFlow.left.isPresent) { Left(resultOrFlow.left.get.asScala()) } else { @@ -199,6 +216,47 @@ object HandlerInvokerFactory { ) } } + if (handlerComponents.httpConfiguration.actionComposition.includeWebSocketActions) { + new play.core.j.JavaAction(handlerComponents) { + override def invocation(req: JRequest): CompletionStage[JResult] = // Simulates called action method + CompletableFuture.completedFuture( + Results.Ok // This Result does not matter, will never be used in the end + .addAttr( // Save request that went through the ActionCreator + annotations + PASS_THROUGH_REQUEST, + req + ) + .asJava + ) + override val annotations = cachedAnnotations( + _annotations, + this.handlerComponents.httpConfiguration.actionComposition, + handlerDef + ) + override val parser = { + // WebSockets do not have a body so we always ignore it and therefore we use the Empty body parser + val javaParser = + this.handlerComponents.getBodyParser(classOf[JBodyParser.Empty]) // Also see Optional.empty() below + javaBodyParserToScala(javaParser) + } + }.apply( + // We never parse a body of a WebSocket request, Optional.empty() is also what JBodyParser.Empty returns + Request(request, new RequestBody(Optional.empty())) + ).flatMap(result => + result.attrs + .get(PASS_THROUGH_REQUEST) + .map(passedThroughRequest => + // So we attached the request before therefore we know it passed through the ActionCreator + annotations + // and nothing did cancel the action chain so we can call the WebSocket now + callWebSocketAction(passedThroughRequest.asScala()) + ) + .getOrElse( + Future + .successful(Left(result)) // An action returned a result so we don't call the WebSocket anymore + ) + ) + } else { + callWebSocketAction(request) + } } } } diff --git a/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala b/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala index a32ca2f7c7f..4df2eb481aa 100644 --- a/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala +++ b/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala @@ -30,6 +30,7 @@ class HttpConfigurationSpec extends Specification { "play.http.parser.allowEmptyFiles" -> "true", "play.http.actionComposition.controllerAnnotationsFirst" -> "true", "play.http.actionComposition.executeActionCreatorActionFirst" -> "true", + "play.http.actionComposition.includeWebSocketActions" -> "true", "play.http.cookies.strict" -> "true", "play.http.session.cookieName" -> "PLAY_SESSION", "play.http.session.secure" -> "true", @@ -193,6 +194,11 @@ class HttpConfigurationSpec extends Specification { val httpConfiguration = new HttpConfiguration.HttpConfigurationProvider(configuration, environment).get httpConfiguration.actionComposition.executeActionCreatorActionFirst must beTrue } + + "include WebSocket actions" in { + val httpConfiguration = new HttpConfiguration.HttpConfigurationProvider(configuration, environment).get + httpConfiguration.actionComposition.includeWebSocketActions must beTrue + } } "configure mime types" in { diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index c5b0abbf2c5..ea879ed45e0 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -458,6 +458,11 @@ object BuildSettings { ), // Added isEmpty method to MultipartFormData ProblemFilters.exclude[ReversedMissingMethodProblem]("play.mvc.Http#MultipartFormData.isEmpty"), + // play.http.actionComposition.includeWebSocketActions + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ActionCompositionConfiguration.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ActionCompositionConfiguration.copy"), + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ActionCompositionConfiguration.this"), + ProblemFilters.exclude[MissingTypesProblem]("play.api.http.ActionCompositionConfiguration$"), ), (Compile / unmanagedSourceDirectories) += { val suffix = CrossVersion.partialVersion(scalaVersion.value) match {