This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit e5cdea90d6f709f9cd01d653967944cec42f27fe Author: Raphael Ouazana <[email protected]> AuthorDate: Thu Jan 28 17:26:19 2021 +0100 JAMES-3491 Experiment sttp for websocket client --- .../jmap-rfc-8621-integration-tests-common/pom.xml | 10 +- .../jmap/rfc8621/contract/WebSocketContract.scala | 540 ++++++++++++--------- .../james/jmap/json/ResponseSerializer.scala | 11 +- 3 files changed, 314 insertions(+), 247 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml index b84b5f6..0fabc14 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml @@ -47,6 +47,11 @@ <artifactId>testing-base</artifactId> </dependency> <dependency> + <groupId>com.softwaremill.sttp.client3</groupId> + <artifactId>okhttp-backend_${scala.base}</artifactId> + <version>3.0.0</version> + </dependency> + <dependency> <groupId>com.typesafe.play</groupId> <artifactId>play-json_${scala.base}</artifactId> </dependency> @@ -62,11 +67,6 @@ <groupId>org.awaitility</groupId> <artifactId>awaitility</artifactId> </dependency> - <dependency> - <groupId>org.java-websocket</groupId> - <artifactId>Java-WebSocket</artifactId> - <version>1.5.1</version> - </dependency> </dependencies> <build> diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebSocketContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebSocketContract.scala index 7bf45a3..0f708c7d 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebSocketContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebSocketContract.scala @@ -18,33 +18,28 @@ ****************************************************************/ package org.apache.james.jmap.rfc8621.contract -import java.net.URI -import java.util -import java.util.concurrent.TimeUnit +import java.net.{ProtocolException, URI} import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson import org.apache.james.GuiceJamesServer import org.apache.james.jmap.draft.JmapGuiceProbe import org.apache.james.jmap.rfc8621.contract.Fixture._ -import org.apache.james.jmap.rfc8621.contract.WebSocketContract.{LOGGER, await} -import org.apache.james.jmap.rfc8621.contract.tags.CategoryTags import org.apache.james.utils.DataProbeImpl -import org.assertj.core.api.Assertions.assertThat -import org.awaitility.Awaitility -import org.java_websocket.client.WebSocketClient -import org.java_websocket.handshake.ServerHandshake -import org.junit.jupiter.api.{BeforeEach, Tag, Test} -import org.slf4j.{Logger, LoggerFactory} - -object WebSocketContract { - val LOGGER: Logger = LoggerFactory.getLogger(classOf[WebSocketContract]) - - val await = Awaitility.await - .atMost(1, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) -} +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.{BeforeEach, Test} +import sttp.capabilities.WebSockets +import sttp.client3.monad.IdMonad +import sttp.client3.okhttp.OkHttpSyncBackend +import sttp.client3.{Identity, RequestT, SttpBackend, asWebSocket, basicRequest} +import sttp.model.Uri +import sttp.monad.MonadError +import sttp.monad.syntax.MonadErrorOps +import sttp.ws.WebSocketFrame +import sttp.ws.WebSocketFrame.Text trait WebSocketContract { + private lazy val backend: SttpBackend[Identity, WebSockets] = OkHttpSyncBackend() + private lazy implicit val monadError: MonadError[Identity] = IdMonad @BeforeEach def setUp(server: GuiceJamesServer): Unit = { @@ -54,264 +49,335 @@ trait WebSocketContract { .addUser(BOB.asString(), BOB_PASSWORD) } - class TestClient(uri: URI) extends WebSocketClient(uri) { - val receivedResponses: util.LinkedList[String] = new util.LinkedList[String]() - var closeString: Option[String] = None - - override def onOpen(serverHandshake: ServerHandshake): Unit = {} - - override def onMessage(s: String): Unit = receivedResponses.add(s) - - override def onClose(i: Int, s: String, b: Boolean): Unit = closeString = Some(s) - - override def onError(e: Exception): Unit = LOGGER.error("WebSocket error", e) - } - @Test - @Tag(CategoryTags.BASIC_FEATURE) def apiRequestsShouldBeProcessed(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send("""{ - | "@type": "Request", - | "requestId": "req-36", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.until(() => client.receivedResponses.size() == 1) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "@type":"Response", - | "requestId":"req-36", - | "sessionState":"2c9f1b12-b35a-43e6-9af2-0106fb53a943", - | "methodResponses":[["Core/echo",{"arg1":"arg1data","arg2":"arg2data"},"c1"]] - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": "Request", + | "requestId": "req-36", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "@type":"Response", + | "requestId":"req-36", + | "sessionState":"2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "methodResponses":[ + | ["Core/echo", + | { + | "arg1":"arg1data", + | "arg2":"arg2data" + | },"c1"] + | ] + |} + |""".stripMargin) } @Test def apiRequestsShouldBeProcessedWhenNoRequestId(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send("""{ - | "@type": "Request", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "@type":"Response", - | "requestId":null, - | "sessionState":"2c9f1b12-b35a-43e6-9af2-0106fb53a943", - | "methodResponses":[["Core/echo",{"arg1":"arg1data","arg2":"arg2data"},"c1"]] - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": "Request", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "@type":"Response", + | "requestId":null, + | "sessionState":"2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "methodResponses":[["Core/echo",{"arg1":"arg1data","arg2":"arg2data"},"c1"]] + |}""".stripMargin) } @Test def nonJsonPayloadShouldTriggerError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send("The quick brown fox".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "status":400, - | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unrecognized token 'The': was expecting ('true', 'false' or 'null')\n at [Source: (String)\"The quick brown fox\"; line: 1, column: 4]),ArraySeq()))))", - | "type":"urn:ietf:params:jmap:error:notRequest", - | "requestId":null, - | "@type":"RequestError" - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text("The quick brown fox")) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "status":400, + | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unrecognized token 'The': was expecting ('true', 'false' or 'null')\n at [Source: (String)\"The quick brown fox\"; line: 1, column: 4]),ArraySeq()))))", + | "type":"urn:ietf:params:jmap:error:notRequest", + | "requestId":null, + | "@type":"RequestError" + |}""".stripMargin) } @Test def handshakeShouldBeAuthenticated(server: GuiceJamesServer): Unit = { - val client: TestClient = unauthenticatedWebSocketClient(server) - client.connectBlocking() - - assertThat(client.isClosed).isTrue - assertThat(client.closeString).isEqualTo(Some("Invalid status code received: 401 Status line: HTTP/1.1 401 Unauthorized")) + assertThatThrownBy(() => + unauthenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text("The quick brown fox")) + + ws.receive() + .map { case t: Text => t.toString } + }) + .send(backend) + .body) + .hasRootCause(new ProtocolException("Expected HTTP 101 response but was '401 Unauthorized'")) } @Test - def noTypeFiledShouldTriggerError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send("""{ - | "requestId": "req-36", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.until(() => client.receivedResponses.size() == 1) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "status":400, - | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Missing @type filed on a webSocket inbound message),ArraySeq()))))", - | "type":"urn:ietf:params:jmap:error:notRequest", - | "requestId":null, - | "@type":"RequestError" - |}""".stripMargin) + def noTypeFieldShouldTriggerError(server: GuiceJamesServer): Unit = { + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "requestId": "req-36", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "status":400, + | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Missing @type field on a webSocket inbound message),ArraySeq()))))", + | "type":"urn:ietf:params:jmap:error:notRequest", + | "requestId":null, + | "@type":"RequestError" + |}""".stripMargin) } @Test def badTypeFieldShouldTriggerError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send("""{ - | "@type": 42, - | "requestId": "req-36", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo( - """{ - | "status":400, - | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Invalid @type filed on a webSocket inbound message: expecting a JsString, got 42),ArraySeq()))))", - | "type":"urn:ietf:params:jmap:error:notRequest", - | "requestId":null, - | "@type":"RequestError" - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": 42, + | "requestId": "req-36", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "status":400, + | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Invalid @type field on a webSocket inbound message: expecting a JsString, got 42),ArraySeq()))))", + | "type":"urn:ietf:params:jmap:error:notRequest", + | "requestId":null, + | "@type":"RequestError" + |}""".stripMargin) } @Test def unknownTypeFieldShouldTriggerError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send( - """{ - | "@type": "unknown", - | "requestId": "req-36", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "status":400, - | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unknown @type filed on a webSocket inbound message: unknown),ArraySeq()))))", - | "type":"urn:ietf:params:jmap:error:notRequest", - | "requestId":null, - | "@type":"RequestError" - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": "unknown", + | "requestId": "req-36", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "status":400, + | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unknown @type field on a webSocket inbound message: unknown),ArraySeq()))))", + | "type":"urn:ietf:params:jmap:error:notRequest", + | "requestId":null, + | "@type":"RequestError" + |}""".stripMargin) } - @Test def clientSendingARespondTypeFieldShouldTriggerError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send( - """{ - | "@type": "Response", - | "requestId": "req-36", - | "using": [ "urn:ietf:params:jmap:core"], - | "methodCalls": [ - | [ - | "Core/echo", - | { - | "arg1": "arg1data", - | "arg2": "arg2data" - | }, - | "c1" - | ] - | ] - |}""".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "status":400, - | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unknown @type filed on a webSocket inbound message: Response),ArraySeq()))))", - | "type":"urn:ietf:params:jmap:error:notRequest", - | "requestId":null, - | "@type":"RequestError" - |}""".stripMargin) + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": "Response", + | "requestId": "req-36", + | "using": [ "urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Core/echo", + | { + | "arg1": "arg1data", + | "arg2": "arg2data" + | }, + | "c1" + | ] + | ] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "status":400, + | "detail":"The request was successfully parsed as JSON but did not match the type signature of the Request object: List((,List(JsonValidationError(List(Unknown @type field on a webSocket inbound message: Response),ArraySeq()))))", + | "type":"urn:ietf:params:jmap:error:notRequest", + | "requestId":null, + | "@type":"RequestError" + |}""".stripMargin) } @Test def requestLevelErrorShouldReturnAPIError(server: GuiceJamesServer): Unit = { - val client: TestClient = authenticatedWebSocketClient(server) - client.connectBlocking() - client.send(s"""{ - | "@type": "Request", - | "using": [ - | "urn:ietf:params:jmap:core", - | "urn:ietf:params:jmap:mail"], - | "methodCalls": [[ - | "Mailbox/get", - | { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "properties": ["invalidProperty"] - | }, - | "c1"]] + val response: Either[String, String] = + authenticatedRequest(server) + .response(asWebSocket[Identity, String] { + ws => + ws.send(WebSocketFrame.text( + """{ + | "@type": "Request", + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [[ + | "Mailbox/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "properties": ["invalidProperty"] + | }, + | "c1"]] + |}""".stripMargin)) + + ws.receive() + .map { case t: Text => t.payload } + }) + .send(backend) + .body + + assertThatJson(response.toOption.get) + .isEqualTo("""{ + | "@type": "Response", + | "requestId": null, + | "sessionState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "methodResponses": [["error",{"type":"invalidArguments","description":"The following properties [invalidProperty] do not exist."},"c1"]] |}""".stripMargin) - - await.untilAsserted(() => assertThat(client.receivedResponses).hasSize(1)) - assertThatJson(client.receivedResponses.get(0)).isEqualTo("""{ - | "@type": "Response", - | "requestId": null, - | "sessionState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", - | "methodResponses": [["error",{"type":"invalidArguments","description":"The following properties [invalidProperty] do not exist."},"c1"]] - |}""".stripMargin) } - private def unauthenticatedWebSocketClient(server: GuiceJamesServer): TestClient = { + private def authenticatedRequest(server: GuiceJamesServer): RequestT[Identity, Either[String, String], Any] = { val port = server.getProbe(classOf[JmapGuiceProbe]) .getJmapPort .getValue - val client = new TestClient(new URI(s"ws://127.0.0.1:$port/jmap/ws")) - client.addHeader("Accept", ACCEPT_RFC8621_VERSION_HEADER) - client + + basicRequest.get(Uri.apply(new URI(s"ws://127.0.0.1:$port/jmap/ws"))) + .header("Authorization", "Basic Ym9iQGRvbWFpbi50bGQ6Ym9icGFzc3dvcmQ=") + .header("Accept", ACCEPT_RFC8621_VERSION_HEADER) } - private def authenticatedWebSocketClient(server: GuiceJamesServer): TestClient = { - val client = unauthenticatedWebSocketClient(server) - client.addHeader("Authorization", "Basic Ym9iQGRvbWFpbi50bGQ6Ym9icGFzc3dvcmQ=") - client + private def unauthenticatedRequest(server: GuiceJamesServer): RequestT[Identity, Either[String, String], Any] = { + val port = server.getProbe(classOf[JmapGuiceProbe]) + .getJmapPort + .getValue + + basicRequest.get(Uri.apply(new URI(s"ws://127.0.0.1:$port/jmap/ws"))) + .header("Accept", ACCEPT_RFC8621_VERSION_HEADER) } } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala index ebf7eb4..71e04f7 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala @@ -19,6 +19,9 @@ package org.apache.james.jmap.json +import java.io.InputStream +import java.net.URL + import eu.timepit.refined.refineV import io.netty.handler.codec.http.HttpResponseStatus import org.apache.james.core.Username @@ -31,8 +34,6 @@ import org.apache.james.jmap.core.{Account, Invocation, Session, _} import play.api.libs.functional.syntax._ import play.api.libs.json._ -import java.io.InputStream -import java.net.URL import scala.collection.{Seq => LegacySeq} import scala.language.implicitConversions import scala.util.Try @@ -188,9 +189,9 @@ object ResponseSerializer { case json: JsObject => json.value.get("@type") match { case Some(JsString("Request")) => webSocketRequestReads.reads(json) - case Some(JsString(unknownType)) => JsError(s"Unknown @type filed on a webSocket inbound message: $unknownType") - case Some(invalidType) => JsError(s"Invalid @type filed on a webSocket inbound message: expecting a JsString, got $invalidType") - case None => JsError(s"Missing @type filed on a webSocket inbound message") + case Some(JsString(unknownType)) => JsError(s"Unknown @type field on a webSocket inbound message: $unknownType") + case Some(invalidType) => JsError(s"Invalid @type field on a webSocket inbound message: expecting a JsString, got $invalidType") + case None => JsError(s"Missing @type field on a webSocket inbound message") } case _ => JsError("Expecting a JsObject to represent a webSocket inbound message") } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
