Skip to content

Commit

Permalink
Merge pull request #11933 from mkurz/includeWebSocketActions
Browse files Browse the repository at this point in the history
Introduce `play.http.actionComposition.includeWebSocketActions`
  • Loading branch information
mkurz committed Aug 21, 2023
2 parents dbd9a55 + eba67f5 commit dc5be30
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 15 deletions.
4 changes: 4 additions & 0 deletions core/play/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

/**
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion core/play/src/main/scala/play/api/mvc/Action.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
82 changes: 70 additions & 12 deletions core/play/src/main/scala/play/core/routing/HandlerInvoker.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions project/BuildSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit dc5be30

Please sign in to comment.