Repository: encode/starlette

Issue:
Transfer JSON as text over WebSockets
I think we usually send JSON data as a text, like `ws.send(JSON.stringify(...))`.


Discussion:
Okay. It’s less correct, but probably more pragmatic.
May be some finer details to consider here, also we’ll need to figure out how best to deal with this as from the POV of not breaking existing systems.

Resolving patch:
diff --git a/starlette/endpoints.py b/starlette/endpoints.py
--- a/starlette/endpoints.py
+++ b/starlette/endpoints.py
@@ -84,21 +84,21 @@ async def decode(self, websocket: WebSocket, message: Message) -> typing.Any:
             return message["bytes"]
 
         elif self.encoding == "json":
+            if message.get("text") is not None:
+                text = message["text"]
+            else:
+                text = message["bytes"].decode("utf-8")
+
             try:
-                if "text" in message:
-                    message_json = json.loads(message["text"])
-                elif message["bytes"]:
-                    message_json = json.loads(message["bytes"].decode("utf-8"))
+                return json.loads(text)
             except json.decoder.JSONDecodeError:
                 await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA)
                 raise RuntimeError("Malformed JSON data received.")
-            else:
-                return message_json
 
         assert (
             self.encoding is None
         ), f"Unsupported 'encoding' attribute {self.encoding}"
-        return message["text"] if "text" in message else message["bytes"]
+        return message["text"] if message.get("text") else message["bytes"]
 
     async def on_connect(self, websocket: WebSocket) -> None:
         """Override to handle an incoming websocket connection"""
diff --git a/starlette/testclient.py b/starlette/testclient.py
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -286,9 +286,13 @@ def send_text(self, data: str) -> None:
     def send_bytes(self, data: bytes) -> None:
         self.send({"type": "websocket.receive", "bytes": data})
 
-    def send_json(self, data: typing.Any) -> None:
-        encoded = json.dumps(data).encode("utf-8")
-        self.send({"type": "websocket.receive", "bytes": encoded})
+    def send_json(self, data: typing.Any, mode: str = "text") -> None:
+        assert mode in ["text", "binary"]
+        text = json.dumps(data)
+        if mode == "text":
+            self.send({"type": "websocket.receive", "text": text})
+        else:
+            self.send({"type": "websocket.receive", "bytes": text.encode("utf-8")})
 
     def close(self, code: int = 1000) -> None:
         self.send({"type": "websocket.disconnect", "code": code})
@@ -309,11 +313,15 @@ def receive_bytes(self) -> bytes:
         self._raise_on_close(message)
         return message["bytes"]
 
-    def receive_json(self) -> typing.Any:
+    def receive_json(self, mode: str = "text") -> typing.Any:
+        assert mode in ["text", "binary"]
         message = self.receive()
         self._raise_on_close(message)
-        encoded = message["bytes"]
-        return json.loads(encoded.decode("utf-8"))
+        if mode == "text":
+            text = message["text"]
+        else:
+            text = message["bytes"].decode("utf-8")
+        return json.loads(text)
 
 
 class TestClient(requests.Session):
diff --git a/starlette/websockets.py b/starlette/websockets.py
--- a/starlette/websockets.py
+++ b/starlette/websockets.py
@@ -92,12 +92,17 @@ async def receive_bytes(self) -> bytes:
         self._raise_on_disconnect(message)
         return message["bytes"]
 
-    async def receive_json(self) -> typing.Any:
+    async def receive_json(self, mode: str = "text") -> typing.Any:
+        assert mode in ["text", "binary"]
         assert self.application_state == WebSocketState.CONNECTED
         message = await self.receive()
         self._raise_on_disconnect(message)
-        encoded = message["bytes"]
-        return json.loads(encoded.decode("utf-8"))
+
+        if mode == "text":
+            text = message["text"]
+        else:
+            text = message["bytes"].decode("utf-8")
+        return json.loads(text)
 
     async def send_text(self, data: str) -> None:
         await self.send({"type": "websocket.send", "text": data})
@@ -105,9 +110,13 @@ async def send_text(self, data: str) -> None:
     async def send_bytes(self, data: bytes) -> None:
         await self.send({"type": "websocket.send", "bytes": data})
 
-    async def send_json(self, data: typing.Any) -> None:
-        encoded = json.dumps(data).encode("utf-8")
-        await self.send({"type": "websocket.send", "bytes": encoded})
+    async def send_json(self, data: typing.Any, mode: str = "text") -> None:
+        assert mode in ["text", "binary"]
+        text = json.dumps(data)
+        if mode == "text":
+            await self.send({"type": "websocket.send", "text": text})
+        else:
+            await self.send({"type": "websocket.send", "bytes": text.encode("utf-8")})
 
     async def close(self, code: int = 1000) -> None:
         await self.send({"type": "websocket.close", "code": code})


Test patch:
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -86,6 +86,20 @@ async def on_receive(self, websocket, data):
             websocket.send_text("Hello world")
 
 
+def test_websocket_endpoint_on_receive_json_binary():
+    class WebSocketApp(WebSocketEndpoint):
+        encoding = "json"
+
+        async def on_receive(self, websocket, data):
+            await websocket.send_json({"message": data}, mode="binary")
+
+    client = TestClient(WebSocketApp)
+    with client.websocket_connect("/ws") as websocket:
+        websocket.send_json({"hello": "world"}, mode="binary")
+        data = websocket.receive_json(mode="binary")
+        assert data == {"message": {"hello": "world"}}
+
+
 def test_websocket_endpoint_on_receive_text():
     class WebSocketApp(WebSocketEndpoint):
         encoding = "text"
diff --git a/tests/test_websockets.py b/tests/test_websockets.py
--- a/tests/test_websockets.py
+++ b/tests/test_websockets.py
@@ -21,6 +21,24 @@ async def asgi(receive, send):
         assert data == {"url": "ws://testserver/123?a=abc"}
 
 
+def test_websocket_binary_json():
+    def app(scope):
+        async def asgi(receive, send):
+            websocket = WebSocket(scope, receive=receive, send=send)
+            await websocket.accept()
+            message = await websocket.receive_json(mode="binary")
+            await websocket.send_json(message, mode="binary")
+            await websocket.close()
+
+        return asgi
+
+    client = TestClient(app)
+    with client.websocket_connect("/123?a=abc") as websocket:
+        websocket.send_json({"test": "data"}, mode="binary")
+        data = websocket.receive_json(mode="binary")
+        assert data == {"test": "data"}
+
+
 def test_websocket_query_params():
     def app(scope):
         async def asgi(receive, send):